teapot-coding-agent 0.22.13 → 0.24.0

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.
@@ -19,56 +19,227 @@
19
19
  import { promises as fs } from "node:fs";
20
20
  import path from "node:path";
21
21
  export const SKILL_FILE = "SKILL.md";
22
+ /**
23
+ * Cheap change-detector for a set of skill roots. Returns a stable string
24
+ * that changes whenever a root is added, removed, or modified — so callers
25
+ * can skip a full rescan (a readdir per root plus a read of every SKILL.md)
26
+ * on the overwhelmingly common "nothing changed" path.
27
+ *
28
+ * The fingerprint covers each root's own mtime/size AND the mtime/size of
29
+ * every SKILL.md one level down, because writing SKILL.md does not bump the
30
+ * parent directory's mtime. Helper scripts edited in place are covered by
31
+ * the SKILL.md stat only in aggregate; a rewritten SKILL.md always lands
32
+ * here too (saveSkill rewrites it), so bundled-file edits ride along with
33
+ * the next skill write rather than triggering their own rescan.
34
+ */
35
+ export async function skillRootsFingerprint(roots) {
36
+ const parts = [];
37
+ for (const root of roots) {
38
+ parts.push(root.dir);
39
+ let st = null;
40
+ try {
41
+ st = await fs.stat(root.dir);
42
+ }
43
+ catch {
44
+ parts.push("-"); // root missing → no skills here (same as discoverSkills)
45
+ continue;
46
+ }
47
+ parts.push(`${st.mtimeMs}:${st.size}`);
48
+ let entries;
49
+ try {
50
+ entries = await fs.readdir(root.dir, { withFileTypes: true });
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ for (const e of entries) {
56
+ if (!e.isDirectory() || e.name.startsWith("."))
57
+ continue;
58
+ try {
59
+ const s = await fs.stat(path.join(root.dir, e.name, SKILL_FILE));
60
+ parts.push(`${e.name}=${s.mtimeMs}:${s.size}`);
61
+ }
62
+ catch {
63
+ parts.push(`${e.name}=none`);
64
+ }
65
+ }
66
+ }
67
+ return parts.join("|");
68
+ }
22
69
  export function isValidSkillName(name) {
23
70
  return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
24
71
  }
25
72
  /**
26
73
  * Minimal frontmatter parser for the subset we need: a leading `---` block of
27
74
  * `key: value` lines. No YAML dependency by design.
75
+ *
76
+ * It does understand the two YAML shapes that are common in REAL third-party
77
+ * skills (#28), because without them those files are unreadable:
78
+ * - FOLDED/LITERAL BLOCK SCALARS (`description: >-` … or `|` …), which is how
79
+ * most skill authors write a long description. Reading them naively yields
80
+ * the literal ">-" as the description, which silently destroys the one field
81
+ * the model matches its task against.
82
+ * - QUOTED values, whose surrounding quotes are stripped so a description
83
+ * containing a colon survives.
84
+ * A key whose value is empty (e.g. a nested `metadata:` block) is left out rather
85
+ * than recorded as an empty string.
28
86
  */
29
87
  export function parseSkillMd(text) {
30
88
  const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
31
89
  if (!m)
32
90
  return { meta: {}, body: text.trim() };
91
+ const lines = m[1].split(/\r?\n/);
33
92
  const meta = {};
34
- for (const line of m[1].split(/\r?\n/)) {
35
- const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
36
- if (kv)
37
- meta[kv[1].toLowerCase()] = kv[2].trim();
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const kv = lines[i].match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
95
+ if (!kv)
96
+ continue;
97
+ const key = kv[1].toLowerCase();
98
+ const raw = kv[2].trim();
99
+ // folded (>-, >, |-, |) or literal (|, |-) block scalar: the value is the
100
+ // following, more-indented lines
101
+ if (/^[>|][-+]?$/.test(raw)) {
102
+ const parts = [];
103
+ let j = i + 1;
104
+ for (; j < lines.length; j++) {
105
+ const l = lines[j];
106
+ if (l.trim() === "") {
107
+ parts.push("");
108
+ continue;
109
+ }
110
+ if (!/^\s/.test(l))
111
+ break; // dedented → block ended
112
+ parts.push(l.trim());
113
+ }
114
+ i = j - 1;
115
+ const joined = parts.join(/^[>|][-+]?$/.test(raw) && raw.startsWith("|") ? "\n" : " ").trim();
116
+ if (joined)
117
+ meta[key] = joined;
118
+ continue;
119
+ }
120
+ if (raw === "")
121
+ continue; // `metadata:` with a nested block below
122
+ meta[key] = unquote(raw);
38
123
  }
39
124
  return { meta, body: m[2].trim() };
40
125
  }
126
+ /** Strip one layer of matching quotes; a quoted value may itself contain `:`. */
127
+ function unquote(v) {
128
+ const s = v.trim();
129
+ if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
130
+ return s
131
+ .slice(1, -1)
132
+ .replace(/\\"/g, '"')
133
+ .replace(/''/g, "'");
134
+ }
135
+ return s;
136
+ }
137
+ /** Root dirs in priority order: [0] overrides later ones on name clash. */
138
+ /**
139
+ * Skill directories owned by OTHER coding harnesses (#28).
140
+ *
141
+ * SKILL.md is a shared convention, so these are readable as-is — the only
142
+ * thing that ever blocked them was our frontmatter parser (folded scalars and
143
+ * quoted values), now handled. Paths are the documented native locations,
144
+ * plus the cross-tool directories several tools also read.
145
+ *
146
+ * Priority is deliberately the LOWEST of the user-supplied roots: a project
147
+ * should never be shadowed by another tool's copy of the same skill. Missing
148
+ * directories are skipped silently, and nothing is read unless it exists.
149
+ */
150
+ export function foreignSkillRoots(workspace) {
151
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
152
+ const out = [];
153
+ const add = (dir) => {
154
+ if (dir && !out.some((r) => r.dir === dir))
155
+ out.push({ dir, source: "foreign" });
156
+ };
157
+ if (workspace) {
158
+ // project-local locations other tools read
159
+ for (const rel of [
160
+ path.join(".agents", "skills"), // cross-tool convention
161
+ path.join(".claude", "skills"),
162
+ path.join(".codex", "skills"),
163
+ path.join(".cursor", "skills"),
164
+ path.join(".gemini", "skills"),
165
+ path.join(".github", "skills"), // Copilot CLI + VS Code
166
+ path.join(".opencode", "skills"),
167
+ ]) {
168
+ add(path.join(workspace, rel));
169
+ }
170
+ }
171
+ if (home) {
172
+ add(path.join(home, ".agents", "skills")); // cross-tool, personal
173
+ for (const rel of [
174
+ [".claude", "skills"],
175
+ [".codex", "skills"],
176
+ [".cursor", "skills"],
177
+ [".gemini", "skills"],
178
+ [".copilot", "skills"], // GitHub Copilot CLI + VS Code share this
179
+ [".config", "opencode", "skills"],
180
+ ]) {
181
+ add(path.join(home, ...rel));
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+ /**
187
+ * How deep to look for nested skills (#27).
188
+ *
189
+ * Bounded because a skill directory also holds BUNDLED FILES (helper scripts)
190
+ * and arbitrary payload, so an unbounded walk is not safe. Depth is not the
191
+ * thing that makes a directory a skill, though — holding a SKILL.md is — so the
192
+ * bound only limits how far we look, never what counts.
193
+ */
194
+ const MAX_SKILL_DEPTH = 4;
41
195
  /** Root dirs in priority order: [0] overrides later ones on name clash. */
42
196
  export async function discoverSkills(roots) {
43
197
  const byName = new Map();
198
+ // name -> depth, so a SHALLOWER skill wins over a nested namesake rather than
199
+ // a deep one silently shadowing the obvious skill (#27)
200
+ const depthByName = new Map();
44
201
  for (const root of roots) {
45
- let entries;
46
- try {
47
- entries = await fs.readdir(root.dir, { withFileTypes: true });
48
- }
49
- catch {
50
- continue; // root missing → simply no skills there
51
- }
52
- for (const e of entries) {
53
- if (!e.isDirectory() || e.name.startsWith("."))
54
- continue;
55
- const filePath = path.join(root.dir, e.name, SKILL_FILE);
56
- let text;
57
- try {
58
- text = await fs.readFile(filePath, "utf8");
59
- }
60
- catch {
61
- continue; // directory without SKILL.md is not a skill
62
- }
63
- const parsed = parseSkillMd(text);
64
- const name = parsed.meta.name || e.name;
65
- if (byName.has(name))
66
- continue; // higher-priority root already defined it
67
- // collect bundled files (helper scripts, templates, ...) next to SKILL.md
202
+ await walkSkills(root.dir, root.source, 0, byName, depthByName);
203
+ }
204
+ return [...byName.values()];
205
+ }
206
+ /**
207
+ * Depth-first walk collecting every directory that holds a SKILL.md (#27).
208
+ *
209
+ * A directory WITHOUT its own SKILL.md is only a container to descend into — we
210
+ * never treat one as a skill, which is what keeps bundled helper directories
211
+ * from being reported as skills themselves.
212
+ */
213
+ async function walkSkills(dir, source, depth, byName, depthByName) {
214
+ if (depth > MAX_SKILL_DEPTH)
215
+ return;
216
+ let entries;
217
+ try {
218
+ entries = await fs.readdir(dir, { withFileTypes: true });
219
+ }
220
+ catch {
221
+ return; // root missing → simply no skills there
222
+ }
223
+ // a SKILL.md sitting directly in `dir` makes `dir` a skill AND a container
224
+ const here = path.join(dir, SKILL_FILE);
225
+ let hereText = null;
226
+ try {
227
+ hereText = await fs.readFile(here, "utf8");
228
+ }
229
+ catch {
230
+ hereText = null;
231
+ }
232
+ if (hereText !== null && depth > 0) {
233
+ const parsed = parseSkillMd(hereText);
234
+ const name = parsed.meta.name || path.basename(dir);
235
+ const prevDepth = depthByName.get(name);
236
+ // first root wins on a name clash, and a shallower skill beats a deeper one
237
+ const wins = prevDepth === undefined ||
238
+ (prevDepth !== undefined && depth < prevDepth && byName.get(name)?.source === source);
239
+ if (wins) {
68
240
  let files = [];
69
241
  try {
70
- const siblings = await fs.readdir(path.join(root.dir, e.name), { withFileTypes: true });
71
- files = siblings
242
+ files = (await fs.readdir(dir, { withFileTypes: true }))
72
243
  .filter((f) => f.isFile() && f.name !== SKILL_FILE && !f.name.startsWith("."))
73
244
  .map((f) => f.name)
74
245
  .sort();
@@ -79,13 +250,22 @@ export async function discoverSkills(roots) {
79
250
  byName.set(name, {
80
251
  name,
81
252
  description: parsed.meta.description || "",
82
- source: root.source,
83
- filePath,
253
+ source,
254
+ filePath: here,
84
255
  files,
85
256
  });
257
+ depthByName.set(name, depth);
86
258
  }
87
259
  }
88
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
260
+ for (const e of entries) {
261
+ if (!e.isDirectory() || e.name.startsWith("."))
262
+ continue;
263
+ // Recurse into every subdirectory: nesting means a skill can sit at ANY depth
264
+ // (skills/stage1/template/SKILL.md), and "stage1" itself has no SKILL.md.
265
+ // Bundled helper payloads are not mistaken for skills because a directory
266
+ // only BECOMES a skill when it holds its own SKILL.md (#27).
267
+ await walkSkills(path.join(dir, e.name), source, depth + 1, byName, depthByName);
268
+ }
89
269
  }
90
270
  export async function readSkillFile(def) {
91
271
  return fs.readFile(def.filePath, "utf8");