teapot-coding-agent 0.23.0 → 0.24.1

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.
@@ -72,50 +72,174 @@ export function isValidSkillName(name) {
72
72
  /**
73
73
  * Minimal frontmatter parser for the subset we need: a leading `---` block of
74
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.
75
86
  */
76
87
  export function parseSkillMd(text) {
77
88
  const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
78
89
  if (!m)
79
90
  return { meta: {}, body: text.trim() };
91
+ const lines = m[1].split(/\r?\n/);
80
92
  const meta = {};
81
- for (const line of m[1].split(/\r?\n/)) {
82
- const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
83
- if (kv)
84
- 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);
85
123
  }
86
124
  return { meta, body: m[2].trim() };
87
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;
88
195
  /** Root dirs in priority order: [0] overrides later ones on name clash. */
89
196
  export async function discoverSkills(roots) {
90
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();
91
201
  for (const root of roots) {
92
- let entries;
93
- try {
94
- entries = await fs.readdir(root.dir, { withFileTypes: true });
95
- }
96
- catch {
97
- continue; // root missing → simply no skills there
98
- }
99
- for (const e of entries) {
100
- if (!e.isDirectory() || e.name.startsWith("."))
101
- continue;
102
- const filePath = path.join(root.dir, e.name, SKILL_FILE);
103
- let text;
104
- try {
105
- text = await fs.readFile(filePath, "utf8");
106
- }
107
- catch {
108
- continue; // directory without SKILL.md is not a skill
109
- }
110
- const parsed = parseSkillMd(text);
111
- const name = parsed.meta.name || e.name;
112
- if (byName.has(name))
113
- continue; // higher-priority root already defined it
114
- // 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) {
115
240
  let files = [];
116
241
  try {
117
- const siblings = await fs.readdir(path.join(root.dir, e.name), { withFileTypes: true });
118
- files = siblings
242
+ files = (await fs.readdir(dir, { withFileTypes: true }))
119
243
  .filter((f) => f.isFile() && f.name !== SKILL_FILE && !f.name.startsWith("."))
120
244
  .map((f) => f.name)
121
245
  .sort();
@@ -126,13 +250,22 @@ export async function discoverSkills(roots) {
126
250
  byName.set(name, {
127
251
  name,
128
252
  description: parsed.meta.description || "",
129
- source: root.source,
130
- filePath,
253
+ source,
254
+ filePath: here,
131
255
  files,
132
256
  });
257
+ depthByName.set(name, depth);
133
258
  }
134
259
  }
135
- 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
+ }
136
269
  }
137
270
  export async function readSkillFile(def) {
138
271
  return fs.readFile(def.filePath, "utf8");