chad-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/session.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Conversation persistence so a session survives across runs (`cli.py --continue`,
|
|
2
|
+
`--resume`, and the TUI `/resume` picker).
|
|
3
|
+
|
|
4
|
+
Native Claude Code records every conversation, can list them, and can fork one; this is
|
|
5
|
+
the local analogue, scoped per project directory (you almost always want to continue the
|
|
6
|
+
work for *this* repo). Only the message list is persisted as JSON — NOT the KV cache: the
|
|
7
|
+
next turn re-prefills the restored transcript (the system-prefix warm cache still
|
|
8
|
+
applies), which is the price of a cold resume. Best-effort throughout: a failure to save
|
|
9
|
+
or load never breaks a turn.
|
|
10
|
+
|
|
11
|
+
Layout (plan 043):
|
|
12
|
+
|
|
13
|
+
~/.chad/sessions/<cwdhash>/<session_id>.json one file per session
|
|
14
|
+
~/.chad/sessions/<cwdhash>/index.json title/updated/turns per session
|
|
15
|
+
|
|
16
|
+
`session_id` = `YYYYMMDD-HHMMSS-<4 hex>`, minted at Agent construction. Each save
|
|
17
|
+
overwrites only *its own* session file, so resuming a session (which mints a fresh id and
|
|
18
|
+
seeds the old messages) never rewrites the original — every resume is implicitly a fork.
|
|
19
|
+
A legacy single-slot `~/.chad/sessions/<cwdhash>.json` file is adopted as one session the
|
|
20
|
+
first time the directory is listed. Retention keeps the newest N per cwd, pruned on save.
|
|
21
|
+
"""
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
SESS_DIR = os.path.expanduser("~/.chad/sessions")
|
|
29
|
+
RETAIN = 20 # keep the newest N sessions per cwd; prune older on save
|
|
30
|
+
_TITLE_LEN = 60 # first-user-message title truncation for the index / picker
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _key(cwd: str) -> str:
|
|
34
|
+
return hashlib.sha1(os.path.abspath(cwd).encode("utf-8", "ignore")).hexdigest()[:16]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _dir(cwd: str) -> str:
|
|
38
|
+
"""Per-cwd session directory."""
|
|
39
|
+
return os.path.join(SESS_DIR, _key(cwd))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _legacy_path(cwd: str) -> str:
|
|
43
|
+
"""The pre-043 single-slot file (`<cwdhash>.json`), adopted on first listing."""
|
|
44
|
+
return os.path.join(SESS_DIR, _key(cwd) + ".json")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _session_path(cwd: str, session_id: str) -> str:
|
|
48
|
+
return os.path.join(_dir(cwd), session_id + ".json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _index_path(cwd: str) -> str:
|
|
52
|
+
return os.path.join(_dir(cwd), "index.json")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def new_session_id() -> str:
|
|
56
|
+
"""A fresh session id: sortable timestamp + 4 random hex (collision-safe within a
|
|
57
|
+
second). Minted at Agent construction; a resume mints a new one → implicit fork."""
|
|
58
|
+
return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _atomic_write_json(path: str, obj) -> bool:
|
|
62
|
+
"""Write `obj` as JSON to `path` atomically at 0600. Returns True on success.
|
|
63
|
+
|
|
64
|
+
Create 0600 from the start so the conversation store (full tool args and results) is
|
|
65
|
+
never briefly world-readable; os.replace preserves the mode and is atomic on POSIX —
|
|
66
|
+
a reader never sees a half-written file."""
|
|
67
|
+
tmp = path + f".{os.getpid()}.tmp"
|
|
68
|
+
try:
|
|
69
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
70
|
+
with os.fdopen(fd, "w") as f:
|
|
71
|
+
json.dump(obj, f)
|
|
72
|
+
os.replace(tmp, path)
|
|
73
|
+
return True
|
|
74
|
+
except OSError:
|
|
75
|
+
try:
|
|
76
|
+
os.remove(tmp)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_path(path: str):
|
|
83
|
+
"""Return a saved {cwd, updated, meta, messages, session_id} dict, or None."""
|
|
84
|
+
if not os.path.isfile(path):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
with open(path) as f:
|
|
88
|
+
data = json.load(f)
|
|
89
|
+
if isinstance(data, dict) and isinstance(data.get("messages"), list):
|
|
90
|
+
return data
|
|
91
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
92
|
+
pass
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _first_user_title(messages: list, limit: int = _TITLE_LEN) -> str:
|
|
97
|
+
"""The first user message, whitespace-collapsed and truncated — the session title."""
|
|
98
|
+
for m in messages:
|
|
99
|
+
if m.get("role") == "user":
|
|
100
|
+
c = m.get("content")
|
|
101
|
+
if isinstance(c, str) and c.strip():
|
|
102
|
+
t = " ".join(c.split())
|
|
103
|
+
return t[:limit] + ("…" if len(t) > limit else "")
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _turns(messages: list) -> int:
|
|
108
|
+
return sum(1 for m in messages if m.get("role") == "user")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -- index (avoids parsing every session file just to list them) -------------
|
|
112
|
+
|
|
113
|
+
def _load_index(cwd: str) -> dict:
|
|
114
|
+
"""The per-cwd index, or an empty one if missing/corrupt (corrupt is tolerated —
|
|
115
|
+
`list_sessions` rebuilds it from the session files on disk)."""
|
|
116
|
+
try:
|
|
117
|
+
with open(_index_path(cwd)) as f:
|
|
118
|
+
data = json.load(f)
|
|
119
|
+
if isinstance(data, dict) and isinstance(data.get("sessions"), dict):
|
|
120
|
+
return data
|
|
121
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
122
|
+
pass
|
|
123
|
+
return {"sessions": {}}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_index(cwd: str, sessions: dict) -> None:
|
|
127
|
+
_atomic_write_json(_index_path(cwd), {"sessions": sessions})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _update_index(cwd: str, session_id: str, messages: list, updated: float) -> None:
|
|
131
|
+
idx = _load_index(cwd)
|
|
132
|
+
idx["sessions"][session_id] = {
|
|
133
|
+
"title": _first_user_title(messages),
|
|
134
|
+
"updated": updated,
|
|
135
|
+
"turns": _turns(messages),
|
|
136
|
+
}
|
|
137
|
+
_write_index(cwd, idx["sessions"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _adopt_legacy(cwd: str) -> None:
|
|
141
|
+
"""Migrate a pre-043 `<cwdhash>.json` file into the sessioned store as one session,
|
|
142
|
+
then remove it so it is adopted exactly once. Best-effort."""
|
|
143
|
+
legacy = _legacy_path(cwd)
|
|
144
|
+
if not os.path.isfile(legacy):
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
data = _load_path(legacy)
|
|
148
|
+
os.makedirs(_dir(cwd), exist_ok=True)
|
|
149
|
+
if data:
|
|
150
|
+
updated = data.get("updated") or time.time()
|
|
151
|
+
# Derive a stable id from the legacy file's own timestamp so re-listing is
|
|
152
|
+
# idempotent even if the write below races; -0000 marks the adopted slot.
|
|
153
|
+
sid = time.strftime("%Y%m%d-%H%M%S", time.localtime(updated)) + "-0000"
|
|
154
|
+
target = _session_path(cwd, sid)
|
|
155
|
+
if not os.path.exists(target):
|
|
156
|
+
_atomic_write_json(target, {
|
|
157
|
+
"cwd": data.get("cwd", os.path.abspath(cwd)),
|
|
158
|
+
"session_id": sid,
|
|
159
|
+
"updated": updated,
|
|
160
|
+
"meta": data.get("meta", {}),
|
|
161
|
+
"messages": data["messages"],
|
|
162
|
+
})
|
|
163
|
+
_update_index(cwd, sid, data["messages"], updated)
|
|
164
|
+
os.remove(legacy)
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_sessions(cwd: str, limit: int = None) -> list:
|
|
170
|
+
"""Sessions for `cwd`, newest first: `[{session_id, title, updated, turns}, ...]`.
|
|
171
|
+
|
|
172
|
+
Adopts a legacy file first, then reconciles the index against the files actually on
|
|
173
|
+
disk — so a corrupt/lost index is rebuilt and orphaned index rows are dropped. Cheap:
|
|
174
|
+
the common path reads only the index."""
|
|
175
|
+
_adopt_legacy(cwd)
|
|
176
|
+
sessions = _load_index(cwd)["sessions"]
|
|
177
|
+
try:
|
|
178
|
+
names = os.listdir(_dir(cwd))
|
|
179
|
+
except OSError:
|
|
180
|
+
names = []
|
|
181
|
+
on_disk = set()
|
|
182
|
+
changed = False
|
|
183
|
+
for name in names:
|
|
184
|
+
if name == "index.json" or not name.endswith(".json") or name.endswith(".tmp"):
|
|
185
|
+
continue
|
|
186
|
+
sid = name[: -len(".json")]
|
|
187
|
+
on_disk.add(sid)
|
|
188
|
+
if sid not in sessions: # file present but unindexed (corrupt index / crash)
|
|
189
|
+
data = _load_path(_session_path(cwd, sid))
|
|
190
|
+
if data:
|
|
191
|
+
sessions[sid] = {
|
|
192
|
+
"title": _first_user_title(data["messages"]),
|
|
193
|
+
"updated": data.get("updated", 0),
|
|
194
|
+
"turns": _turns(data["messages"]),
|
|
195
|
+
}
|
|
196
|
+
changed = True
|
|
197
|
+
for sid in list(sessions): # drop rows whose file was pruned/removed
|
|
198
|
+
if sid not in on_disk:
|
|
199
|
+
del sessions[sid]
|
|
200
|
+
changed = True
|
|
201
|
+
if changed:
|
|
202
|
+
_write_index(cwd, sessions)
|
|
203
|
+
items = [{"session_id": sid, **meta} for sid, meta in sessions.items()]
|
|
204
|
+
items.sort(key=lambda it: it.get("updated", 0), reverse=True)
|
|
205
|
+
return items[:limit] if limit else items
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _prune(cwd: str, keep: int = RETAIN) -> None:
|
|
209
|
+
"""Retention: keep the newest `keep` sessions, remove older files + index rows."""
|
|
210
|
+
sessions = _load_index(cwd)["sessions"]
|
|
211
|
+
if len(sessions) <= keep:
|
|
212
|
+
return
|
|
213
|
+
ordered = sorted(sessions.items(), key=lambda kv: kv[1].get("updated", 0), reverse=True)
|
|
214
|
+
for sid, _meta in ordered[keep:]:
|
|
215
|
+
try:
|
|
216
|
+
os.remove(_session_path(cwd, sid))
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
sessions.pop(sid, None)
|
|
220
|
+
_write_index(cwd, sessions)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def save_session(cwd: str, messages: list, meta: dict = None,
|
|
224
|
+
session_id: str = None) -> str:
|
|
225
|
+
"""Atomically persist the conversation for `cwd` to its own session file. Mints a
|
|
226
|
+
`session_id` if none is given. Updates the index and prunes old sessions (best-effort).
|
|
227
|
+
Returns the session file path, or '' on failure."""
|
|
228
|
+
try:
|
|
229
|
+
os.makedirs(_dir(cwd), exist_ok=True)
|
|
230
|
+
session_id = session_id or new_session_id()
|
|
231
|
+
updated = time.time()
|
|
232
|
+
path = _session_path(cwd, session_id)
|
|
233
|
+
ok = _atomic_write_json(path, {"cwd": os.path.abspath(cwd), "session_id": session_id,
|
|
234
|
+
"updated": updated, "meta": meta or {},
|
|
235
|
+
"messages": messages})
|
|
236
|
+
if not ok:
|
|
237
|
+
return ""
|
|
238
|
+
_update_index(cwd, session_id, messages, updated)
|
|
239
|
+
_prune(cwd)
|
|
240
|
+
return path
|
|
241
|
+
except OSError:
|
|
242
|
+
return ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def load_session(cwd: str, session_id: str = None):
|
|
246
|
+
"""Return the saved {cwd, updated, meta, messages, session_id} dict. With `session_id`
|
|
247
|
+
that specific session; without one, the most recent (adopting a legacy file first).
|
|
248
|
+
None if there is nothing to resume."""
|
|
249
|
+
if session_id is None:
|
|
250
|
+
items = list_sessions(cwd, limit=1)
|
|
251
|
+
if not items:
|
|
252
|
+
return None
|
|
253
|
+
session_id = items[0]["session_id"]
|
|
254
|
+
return _load_path(_session_path(cwd, session_id))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _ago(age_s: int) -> str:
|
|
258
|
+
if age_s >= 3600:
|
|
259
|
+
return f"{age_s // 3600}h ago"
|
|
260
|
+
if age_s >= 60:
|
|
261
|
+
return f"{age_s // 60}m ago"
|
|
262
|
+
return "just now"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def describe(item: dict) -> str:
|
|
266
|
+
"""One-line label for a `list_sessions` entry (the picker / resume notice):
|
|
267
|
+
`2h ago · 14 turns · "fix the flaky retry test…"`."""
|
|
268
|
+
when = _ago(max(0, int(time.time() - item.get("updated", 0))))
|
|
269
|
+
turns = item.get("turns", 0)
|
|
270
|
+
title = item.get("title") or "(no title)"
|
|
271
|
+
return f'{when} · {turns} turn{"s" * (turns != 1)} · "{title}"'
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def session_summary(cwd: str) -> str:
|
|
275
|
+
"""One-line description of the most recent session for `cwd` (the `-c` resume notice)."""
|
|
276
|
+
data = load_session(cwd)
|
|
277
|
+
if not data:
|
|
278
|
+
return ""
|
|
279
|
+
users = _turns(data["messages"])
|
|
280
|
+
when = _ago(max(0, int(time.time() - data.get("updated", 0))))
|
|
281
|
+
return f"{users} prior user turn{'s' * (users != 1)}, last active {when}"
|
chad/skills.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Agent Skills support (https://agentskills.io) — discovery, parsing, disclosure, activation.
|
|
2
|
+
|
|
3
|
+
A *skill* is a directory containing a `SKILL.md` file: YAML frontmatter (at minimum
|
|
4
|
+
`name` + `description`) followed by a markdown body of instructions, optionally
|
|
5
|
+
bundling `scripts/`, `references/`, and `assets/`. This module implements the four
|
|
6
|
+
client-side responsibilities from the spec's "adding skills support" guide:
|
|
7
|
+
|
|
8
|
+
1. Discover — scan project- and user-level skill dirs for `SKILL.md` files.
|
|
9
|
+
2. Parse — leniently extract frontmatter + body (warn, don't crash, on cosmetic
|
|
10
|
+
issues; skip only when there's no usable description / unparseable YAML).
|
|
11
|
+
3. Disclose — `catalog_block()` lists name+description+location for the system prompt
|
|
12
|
+
(tier 1: ~50-100 tokens/skill, loaded at session start).
|
|
13
|
+
4. Activate — `activate(name)` returns the full body wrapped in <skill_content> with a
|
|
14
|
+
bundled-resource listing (tier 2: loaded only when the model asks).
|
|
15
|
+
|
|
16
|
+
Progressive disclosure means the base context stays small: the model sees the catalog
|
|
17
|
+
always, pulls a skill's instructions only when a task matches, and reads bundled
|
|
18
|
+
resources (scripts/refs) on demand via the normal `read` tool.
|
|
19
|
+
|
|
20
|
+
State (the cwd-keyed registry and the set of already-activated skills) lives at module
|
|
21
|
+
level — same pattern as `tools._TODOS` — and is cleared by `reset_session()` when a new
|
|
22
|
+
Agent / `/reset` starts so activations don't bleed across sessions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from .diag import log, warn_footer
|
|
28
|
+
from .ignore import IGNORE_DIRS
|
|
29
|
+
|
|
30
|
+
# Bound the discovery walk so a pathological tree (a skills dir nested in a huge repo)
|
|
31
|
+
# can't stall startup. Mirrors the spec's "reasonable bounds" guidance.
|
|
32
|
+
_MAX_DEPTH = 4
|
|
33
|
+
_MAX_DIRS = 2000
|
|
34
|
+
# Directories never worth descending into during a skill scan (shared canonical set).
|
|
35
|
+
_SKIP_DIRS = set(IGNORE_DIRS)
|
|
36
|
+
# Cap the bundled-resource listing returned at activation — a large skill dir shouldn't
|
|
37
|
+
# flood the activation result; the model reads specific files on demand anyway.
|
|
38
|
+
_MAX_RESOURCES = 50
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Skill:
|
|
42
|
+
"""One discovered skill: the parsed metadata plus where it lives on disk."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("name", "description", "location", "base_dir", "body",
|
|
45
|
+
"frontmatter", "warnings")
|
|
46
|
+
|
|
47
|
+
def __init__(self, name, description, location, body, frontmatter, warnings):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.description = description
|
|
50
|
+
self.location = location # absolute path to SKILL.md
|
|
51
|
+
self.base_dir = os.path.dirname(location)
|
|
52
|
+
self.body = body # markdown after the frontmatter
|
|
53
|
+
self.frontmatter = frontmatter # full parsed dict (license/compat/metadata…)
|
|
54
|
+
self.warnings = warnings # lenient-validation diagnostics
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Parsing
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _split_frontmatter(text: str):
|
|
62
|
+
"""Return (frontmatter_yaml, body) for a `---`-delimited file, or (None, text).
|
|
63
|
+
|
|
64
|
+
The opening `---` must be the first line; the closing `---` is the next line that
|
|
65
|
+
is exactly `---`. Everything between is YAML; everything after, trimmed, is the body.
|
|
66
|
+
"""
|
|
67
|
+
# Tolerate a leading BOM / blank lines before the first delimiter.
|
|
68
|
+
stripped = text.lstrip("")
|
|
69
|
+
lines = stripped.splitlines(keepends=True)
|
|
70
|
+
if not lines or lines[0].strip() != "---":
|
|
71
|
+
return None, text
|
|
72
|
+
for i in range(1, len(lines)):
|
|
73
|
+
if lines[i].strip() == "---":
|
|
74
|
+
fm = "".join(lines[1:i])
|
|
75
|
+
body = "".join(lines[i + 1:]).strip()
|
|
76
|
+
return fm, body
|
|
77
|
+
return None, text # opened but never closed -> treat as bodyless/invalid
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _quote_colon_values(fm: str) -> str:
|
|
81
|
+
"""Best-effort fix for the most common cross-client YAML breakage: an unquoted
|
|
82
|
+
scalar whose value contains a colon-space (`description: Use when: foo`), which a
|
|
83
|
+
strict YAML parser rejects. Wrap such top-level values in double quotes and retry.
|
|
84
|
+
Only touches obvious `key: value` lines; leaves nested/structured YAML alone."""
|
|
85
|
+
out = []
|
|
86
|
+
for ln in fm.splitlines():
|
|
87
|
+
stripped = ln.strip()
|
|
88
|
+
# key at column 0 (no indent), value present, value contains a colon-space,
|
|
89
|
+
# and isn't already quoted / a block scalar / a mapping opener.
|
|
90
|
+
if (ln[:1] not in (" ", "\t", "#", "")
|
|
91
|
+
and ":" in stripped):
|
|
92
|
+
key, _, val = ln.partition(":")
|
|
93
|
+
val_s = val.strip()
|
|
94
|
+
if (val_s and ": " in val_s
|
|
95
|
+
and val_s[0] not in ("'", '"', "|", ">", "[", "{")):
|
|
96
|
+
esc = val_s.replace("\\", "\\\\").replace('"', '\\"')
|
|
97
|
+
out.append(f'{key}: "{esc}"')
|
|
98
|
+
continue
|
|
99
|
+
out.append(ln)
|
|
100
|
+
return "\n".join(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_skill_text(text: str, location: str):
|
|
104
|
+
"""Parse one SKILL.md's text. Returns (Skill, None) on success or (None, reason) when
|
|
105
|
+
the skill must be skipped (no usable description, or unparseable YAML). Lenient: a bad
|
|
106
|
+
`name` is repaired/derived and recorded as a warning rather than rejected."""
|
|
107
|
+
import yaml
|
|
108
|
+
|
|
109
|
+
fm_text, body = _split_frontmatter(text)
|
|
110
|
+
if fm_text is None:
|
|
111
|
+
return None, "no YAML frontmatter (missing or unterminated `---` block)"
|
|
112
|
+
try:
|
|
113
|
+
meta = yaml.safe_load(fm_text)
|
|
114
|
+
except yaml.YAMLError:
|
|
115
|
+
try:
|
|
116
|
+
meta = yaml.safe_load(_quote_colon_values(fm_text))
|
|
117
|
+
except yaml.YAMLError as e:
|
|
118
|
+
return None, f"unparseable YAML frontmatter: {e}"
|
|
119
|
+
if not isinstance(meta, dict):
|
|
120
|
+
return None, "frontmatter is not a key-value mapping"
|
|
121
|
+
|
|
122
|
+
warnings = []
|
|
123
|
+
desc = meta.get("description")
|
|
124
|
+
desc = desc.strip() if isinstance(desc, str) else ""
|
|
125
|
+
if not desc:
|
|
126
|
+
# A description is essential for disclosure — without it the model can never
|
|
127
|
+
# know when to activate the skill. Skip (the one hard failure besides bad YAML).
|
|
128
|
+
return None, "missing or empty `description`"
|
|
129
|
+
if len(desc) > 1024:
|
|
130
|
+
warnings.append(f"description exceeds 1024 chars ({len(desc)})")
|
|
131
|
+
|
|
132
|
+
dir_name = os.path.basename(os.path.dirname(location))
|
|
133
|
+
name = meta.get("name")
|
|
134
|
+
name = name.strip() if isinstance(name, str) else ""
|
|
135
|
+
if not name:
|
|
136
|
+
name = dir_name
|
|
137
|
+
warnings.append("missing `name`; using parent directory name")
|
|
138
|
+
if name != dir_name:
|
|
139
|
+
warnings.append(f"name {name!r} does not match directory {dir_name!r}")
|
|
140
|
+
if len(name) > 64:
|
|
141
|
+
warnings.append(f"name exceeds 64 chars ({len(name)})")
|
|
142
|
+
|
|
143
|
+
return Skill(name, desc, location, body, meta, warnings), None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_skill_file(location: str):
|
|
147
|
+
"""Read and parse a SKILL.md at `location`. Returns (Skill, None) or (None, reason)."""
|
|
148
|
+
try:
|
|
149
|
+
with open(location, "r", errors="replace") as f:
|
|
150
|
+
text = f.read()
|
|
151
|
+
except OSError as e:
|
|
152
|
+
return None, f"cannot read: {e}"
|
|
153
|
+
return parse_skill_text(text, os.path.abspath(location))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Discovery
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _scope_roots(cwd: str, home: str):
|
|
161
|
+
"""The directories to scan, lowest precedence first. Project-level skills override
|
|
162
|
+
user-level ones (the universal convention), so projects come last and win on merge.
|
|
163
|
+
Both the cross-client `.agents/skills` convention and the pragmatic `.claude/skills`
|
|
164
|
+
location are scanned at each level."""
|
|
165
|
+
return [
|
|
166
|
+
("user", os.path.join(home, ".claude", "skills")),
|
|
167
|
+
("user", os.path.join(home, ".agents", "skills")),
|
|
168
|
+
("project", os.path.join(cwd, ".claude", "skills")),
|
|
169
|
+
("project", os.path.join(cwd, ".agents", "skills")),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _find_skill_dirs(root: str):
|
|
174
|
+
"""Yield directories under `root` that directly contain a `SKILL.md`, bounded by
|
|
175
|
+
depth and a total-dir cap so a huge tree can't stall the scan."""
|
|
176
|
+
if not os.path.isdir(root):
|
|
177
|
+
return
|
|
178
|
+
seen = 0
|
|
179
|
+
root_depth = root.rstrip(os.sep).count(os.sep)
|
|
180
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
181
|
+
seen += 1
|
|
182
|
+
if seen > _MAX_DIRS:
|
|
183
|
+
log.warning("skills: hit %d-dir scan cap under %s; some skills may be missed",
|
|
184
|
+
_MAX_DIRS, root)
|
|
185
|
+
return
|
|
186
|
+
depth = dirpath.rstrip(os.sep).count(os.sep) - root_depth
|
|
187
|
+
if depth >= _MAX_DEPTH:
|
|
188
|
+
dirnames[:] = []
|
|
189
|
+
# prune noise dirs in-place so os.walk doesn't descend into them
|
|
190
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
191
|
+
if "SKILL.md" in filenames:
|
|
192
|
+
yield os.path.join(dirpath, "SKILL.md")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def discover(cwd: str = None, home: str = None):
|
|
196
|
+
"""Scan all scopes and return (skills_by_name, order, warnings).
|
|
197
|
+
|
|
198
|
+
`skills_by_name` maps name -> Skill with project-over-user precedence applied;
|
|
199
|
+
`order` is the de-duplicated catalog order (stable: discovery order); `warnings`
|
|
200
|
+
collects per-skill diagnostics plus shadow/collision notes for surfacing in /skills.
|
|
201
|
+
"""
|
|
202
|
+
cwd = cwd or os.getcwd()
|
|
203
|
+
home = home or os.path.expanduser("~")
|
|
204
|
+
by_name = {}
|
|
205
|
+
order = []
|
|
206
|
+
warnings = []
|
|
207
|
+
for scope, root in _scope_roots(cwd, home):
|
|
208
|
+
for location in sorted(_find_skill_dirs(root)):
|
|
209
|
+
skill, reason = parse_skill_file(location)
|
|
210
|
+
if skill is None:
|
|
211
|
+
log.warning("skills: skipping %s — %s", location, reason)
|
|
212
|
+
warnings.append(f"{location}: skipped ({reason})")
|
|
213
|
+
continue
|
|
214
|
+
for w in skill.warnings:
|
|
215
|
+
warnings.append(f"{skill.name}: {w}")
|
|
216
|
+
if skill.name in by_name:
|
|
217
|
+
prev = by_name[skill.name]
|
|
218
|
+
# Same name seen again. Project beats user; otherwise first-found wins.
|
|
219
|
+
prev_scope = "project" if _is_project(prev.location, cwd) else "user"
|
|
220
|
+
if scope == "project" and prev_scope == "user":
|
|
221
|
+
warnings.append(
|
|
222
|
+
f"{skill.name}: project skill shadows user skill at {prev.location}")
|
|
223
|
+
by_name[skill.name] = skill # replace in place; keep catalog position
|
|
224
|
+
log.info("skills: %s (project) overrides user copy", skill.name)
|
|
225
|
+
else:
|
|
226
|
+
warnings.append(
|
|
227
|
+
f"{skill.name}: duplicate at {location} shadowed by {prev.location}")
|
|
228
|
+
log.info("skills: %s duplicate at %s ignored", skill.name, location)
|
|
229
|
+
continue
|
|
230
|
+
by_name[skill.name] = skill
|
|
231
|
+
order.append(skill.name)
|
|
232
|
+
return by_name, order, warnings
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_project(location: str, cwd: str) -> bool:
|
|
236
|
+
cwd_abs = os.path.abspath(cwd)
|
|
237
|
+
return os.path.abspath(location).startswith(cwd_abs + os.sep)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Registry (cwd-cached) + session activation state
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
class _Registry:
|
|
245
|
+
def __init__(self, cwd):
|
|
246
|
+
self.cwd = cwd
|
|
247
|
+
self.by_name, self.order, self.warnings = discover(cwd)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_registry = None # cached _Registry for the current cwd
|
|
251
|
+
_activated = set() # names activated this session (for dedupe)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_registry() -> "_Registry":
|
|
255
|
+
"""The skill registry for the current cwd, discovered once and cached. Rebuilt
|
|
256
|
+
automatically if the working directory changed since the last call."""
|
|
257
|
+
global _registry
|
|
258
|
+
cwd = os.getcwd()
|
|
259
|
+
if _registry is None or _registry.cwd != cwd:
|
|
260
|
+
_registry = _Registry(cwd)
|
|
261
|
+
if _registry.order:
|
|
262
|
+
log.info("skills: discovered %d skill(s): %s",
|
|
263
|
+
len(_registry.order), ", ".join(_registry.order))
|
|
264
|
+
return _registry
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def reset_session():
|
|
268
|
+
"""Clear per-session state (activations) and force re-discovery. Called when a new
|
|
269
|
+
Agent / `/reset` starts so a prior session's activated skills don't leak forward."""
|
|
270
|
+
global _registry, _activated
|
|
271
|
+
_registry = None
|
|
272
|
+
_activated = set()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def skill_names():
|
|
276
|
+
"""Names of all available skills (used to constrain the activate_skill tool enum)."""
|
|
277
|
+
return list(get_registry().order)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def has_skills() -> bool:
|
|
281
|
+
return bool(get_registry().order)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Disclosure (tier 1) + activation (tier 2)
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
_CATALOG_INSTRUCTIONS = (
|
|
289
|
+
"# Skills\n"
|
|
290
|
+
"The following skills provide specialized instructions for specific tasks. Each "
|
|
291
|
+
"lists what it does and when to use it. When a task matches a skill's description, "
|
|
292
|
+
"call the `activate_skill` tool with the skill's `name` to load its full "
|
|
293
|
+
"instructions BEFORE proceeding — do not guess the procedure from the name alone. "
|
|
294
|
+
"A skill's instructions may reference bundled files (scripts/, references/, "
|
|
295
|
+
"assets/); read those on demand with the `read` tool, resolving relative paths "
|
|
296
|
+
"against the skill directory reported when you activate it.\n"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def catalog_block(cwd: str = None) -> str:
|
|
301
|
+
"""The tier-1 skills section for the system prompt: behavioral instructions plus a
|
|
302
|
+
compact <available_skills> catalog (name + description + location). Returns "" when
|
|
303
|
+
no skills are installed, so an empty block never confuses the model."""
|
|
304
|
+
reg = get_registry() if cwd is None else _Registry(cwd)
|
|
305
|
+
if not reg.order:
|
|
306
|
+
return ""
|
|
307
|
+
lines = ["\n\n" + _CATALOG_INSTRUCTIONS, "<available_skills>"]
|
|
308
|
+
for name in reg.order:
|
|
309
|
+
s = reg.by_name[name]
|
|
310
|
+
lines.append(" <skill>")
|
|
311
|
+
lines.append(f" <name>{name}</name>")
|
|
312
|
+
lines.append(f" <description>{_one_line(s.description)}</description>")
|
|
313
|
+
lines.append(f" <location>{s.location}</location>")
|
|
314
|
+
lines.append(" </skill>")
|
|
315
|
+
lines.append("</available_skills>")
|
|
316
|
+
return "\n".join(lines)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _one_line(text: str) -> str:
|
|
320
|
+
"""Collapse whitespace so a multi-line description stays on one catalog line."""
|
|
321
|
+
return " ".join(text.split())
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _list_resources(base_dir: str, skill_md: str):
|
|
325
|
+
"""Relative paths of bundled files in the skill dir (excluding SKILL.md itself),
|
|
326
|
+
capped. These are surfaced to the model but NOT read — it loads them on demand."""
|
|
327
|
+
files = []
|
|
328
|
+
truncated = False
|
|
329
|
+
for dirpath, dirnames, filenames in os.walk(base_dir):
|
|
330
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
331
|
+
for fn in sorted(filenames):
|
|
332
|
+
full = os.path.join(dirpath, fn)
|
|
333
|
+
if os.path.abspath(full) == os.path.abspath(skill_md):
|
|
334
|
+
continue
|
|
335
|
+
rel = os.path.relpath(full, base_dir)
|
|
336
|
+
files.append(rel)
|
|
337
|
+
if len(files) >= _MAX_RESOURCES:
|
|
338
|
+
truncated = True
|
|
339
|
+
break
|
|
340
|
+
if truncated:
|
|
341
|
+
break
|
|
342
|
+
return sorted(files), truncated
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def activate(name: str) -> str:
|
|
346
|
+
"""Tier-2 activation: return the named skill's instructions wrapped in identifying
|
|
347
|
+
tags, with its directory and a bundled-resource listing. Frontmatter is stripped
|
|
348
|
+
(the body is the actionable part). Already-activated skills return a short note
|
|
349
|
+
instead of re-injecting the same instructions."""
|
|
350
|
+
reg = get_registry()
|
|
351
|
+
skill = reg.by_name.get(name)
|
|
352
|
+
if skill is None:
|
|
353
|
+
avail = ", ".join(reg.order) or "none installed"
|
|
354
|
+
return (f"[no skill named {name!r}. Available skills: {avail}. "
|
|
355
|
+
f"Call activate_skill with one of those exact names.]")
|
|
356
|
+
if name in _activated:
|
|
357
|
+
return (f"[skill '{name}' is already active earlier in this conversation — its "
|
|
358
|
+
f"instructions are still in effect; no need to re-activate.]")
|
|
359
|
+
_activated.add(name)
|
|
360
|
+
log.info("skills: activated %s (%s)", name, skill.location)
|
|
361
|
+
|
|
362
|
+
resources, truncated = _list_resources(skill.base_dir, skill.location)
|
|
363
|
+
parts = [f'<skill_content name="{name}">', skill.body, "",
|
|
364
|
+
f"Skill directory: {skill.base_dir}",
|
|
365
|
+
"Relative paths in this skill are relative to the skill directory; pass "
|
|
366
|
+
"absolute paths to tools."]
|
|
367
|
+
if resources:
|
|
368
|
+
parts.append("<skill_resources>")
|
|
369
|
+
parts += [f" <file>{r}</file>" for r in resources]
|
|
370
|
+
if truncated:
|
|
371
|
+
parts.append(f" <note>listing capped at {_MAX_RESOURCES} files; more exist</note>")
|
|
372
|
+
parts.append("</skill_resources>")
|
|
373
|
+
parts.append("</skill_content>")
|
|
374
|
+
return "\n".join(parts)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Marker that identifies an activated-skill tool result, so context compaction can
|
|
378
|
+
# protect it from truncation/dropping (skill instructions are durable behavioral
|
|
379
|
+
# guidance — silently losing them mid-session degrades the agent with no error).
|
|
380
|
+
SKILL_CONTENT_MARKER = "<skill_content "
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def is_skill_message(msg: dict) -> bool:
|
|
384
|
+
"""True if a conversation message carries activated skill instructions."""
|
|
385
|
+
return (msg.get("role") == "tool"
|
|
386
|
+
and (msg.get("name") == "activate_skill"
|
|
387
|
+
or SKILL_CONTENT_MARKER in msg.get("content", "")))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def summary_lines():
|
|
391
|
+
"""Human-readable lines for the `/skills` command: one row per skill (name, an
|
|
392
|
+
`*active*` marker if already loaded, and a trimmed description), plus a footer of
|
|
393
|
+
any discovery warnings. Empty list message when no skills are installed."""
|
|
394
|
+
reg = get_registry()
|
|
395
|
+
if not reg.order:
|
|
396
|
+
return ["no skills installed. Add a SKILL.md under .agents/skills/ (project) or "
|
|
397
|
+
"~/.agents/skills/ (user). See https://agentskills.io"]
|
|
398
|
+
out = []
|
|
399
|
+
for name in reg.order:
|
|
400
|
+
s = reg.by_name[name]
|
|
401
|
+
desc = _one_line(s.description)
|
|
402
|
+
if len(desc) > 100:
|
|
403
|
+
desc = desc[:97] + "…"
|
|
404
|
+
active = " *active*" if name in _activated else ""
|
|
405
|
+
out.append(f"{name}{active} — {desc}")
|
|
406
|
+
out += warn_footer(reg.warnings)
|
|
407
|
+
return out
|