teapot-coding-agent 0.37.59 → 0.37.61

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.
Files changed (42) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +61 -2247
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +9 -310
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1180
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +166 -142
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
  37. package/public/assets/index-C1eqcSbW.js.map +1 -0
  38. package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
  39. package/public/assets/shiki-DaLn_bCe.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-KzHw4Sqo.js.map +0 -1
  42. package/public/assets/shiki-BwDjoZdu.js.map +0 -1
@@ -1,40 +1,5 @@
1
- /**
2
- * Agent Plugins (#162): read a `plugin.json` package and turn it into things teapot
3
- * already understands.
4
- *
5
- * The format (https://agent-plugins.org, spec 1.1.0) is deliberately tiny, and the
6
- * whole point of adopting it is that it needs almost no new machinery here:
7
- *
8
- * my-plugin/
9
- * ├── plugin.json manifest: `$schema` + `name` are the only required fields
10
- * ├── skills/<name>/SKILL.md → a skill ROOT (teapot already scans for SKILL.md)
11
- * ├── mcp.json → `mcpServers`, the same shape as teapot's own MCP config
12
- * └── com.example.client/… → client extensions, which we do not implement
13
- *
14
- * So a plugin is loaded by VALIDATING it and then extracting those two components. No
15
- * plugin runtime, no per-plugin process for skills.
16
- *
17
- * What is validated, and why each rule exists:
18
- * - `plugin.json` must exist, be an object, and carry `$schema` and `name`. The spec
19
- * requires both; a manifest without them is not a plugin, and guessing is worse
20
- * than saying so.
21
- * - `name` must match the spec's pattern (lowercase, no `--`, no `..`). The pattern is
22
- * not cosmetic: the name is used in paths and in tool namespaces, so `..` in a name
23
- * is a traversal primitive.
24
- * - **containment**: `plugin.json`, `skills/`, and every discovered `SKILL.md` must
25
- * resolve INSIDE the plugin root after following symlinks. The spec makes this a
26
- * MUST, and it is the one rule with a real security consequence: a plugin shipped
27
- * as an archive can contain a symlink pointing at `~/.ssh`.
28
- * - a `cwd`/`command` in `mcp.json` that escapes the root invalidates that SERVER,
29
- * not the whole plugin — the spec draws exactly that boundary (§4.1).
30
- *
31
- * `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` are expanded in `args`, `env` and `cwd` of a
32
- * stdio server, as the spec requires. `PLUGIN_DATA` is a client-managed per-plugin
33
- * directory, so each plugin gets a stable writable location that survives an upgrade.
34
- */
35
1
  import { readFile, readdir, realpath, mkdir, stat } from "node:fs/promises";
36
2
  import path from "node:path";
37
- /** the spec's name pattern, verbatim from plugin.schema.json */
38
3
  const NAME_RE = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
39
4
  export class PluginError extends Error {
40
5
  constructor(message) {
@@ -42,19 +7,10 @@ export class PluginError extends Error {
42
7
  this.name = "PluginError";
43
8
  }
44
9
  }
45
- /** true when `child` is `parent` or inside it (both already resolved) */
46
10
  function within(parent, child) {
47
11
  const rel = path.relative(parent, child);
48
12
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
49
13
  }
50
- /**
51
- * Resolve a path and prove it stays inside `root`.
52
- *
53
- * `realpath` FIRST, because a symlink is exactly how an archive escapes its own root —
54
- * checking the literal path would let `skills/evil -> /etc` through. A path that does
55
- * not exist cannot escape, so ENOENT returns the lexical join and lets the caller's own
56
- * existence checks report it.
57
- */
58
14
  async function resolveInside(root, p) {
59
15
  const abs = path.resolve(root, p);
60
16
  try {
@@ -65,16 +21,9 @@ async function resolveInside(root, p) {
65
21
  return within(root, abs) ? abs : null;
66
22
  }
67
23
  }
68
- /** expand `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` (the only two the spec defines) */
69
24
  function expand(s, root, data) {
70
25
  return s.split("${PLUGIN_ROOT}").join(root).split("${PLUGIN_DATA}").join(data);
71
26
  }
72
- /**
73
- * Load and validate one plugin directory.
74
- *
75
- * @param dir the plugin root
76
- * @param dataDir the client-managed `PLUGIN_DATA` directory for this plugin
77
- */
78
27
  export async function loadPlugin(dir, dataDir) {
79
28
  let root;
80
29
  try {
@@ -86,7 +35,6 @@ export async function loadPlugin(dir, dataDir) {
86
35
  const st = await stat(root).catch(() => null);
87
36
  if (!st?.isDirectory())
88
37
  throw new PluginError(`not a directory: ${dir}`);
89
- // ---- manifest ----
90
38
  const manifestPath = await resolveInside(root, "plugin.json");
91
39
  if (!manifestPath)
92
40
  throw new PluginError("plugin.json resolves outside the plugin root");
@@ -106,8 +54,6 @@ export async function loadPlugin(dir, dataDir) {
106
54
  }
107
55
  if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest))
108
56
  throw new PluginError("plugin.json must be a JSON object");
109
- // the spec makes BOTH required; without $schema we cannot know which version's rules
110
- // to apply, and silently assuming 1.1.0 is how a v2 plugin gets half-loaded
111
57
  if (typeof manifest.$schema !== "string")
112
58
  throw new PluginError("plugin.json is missing $schema (an Agent Plugins manifest requires it)");
113
59
  if (typeof manifest.name !== "string" || !manifest.name)
@@ -126,11 +72,6 @@ export async function loadPlugin(dir, dataDir) {
126
72
  out.version = manifest.version;
127
73
  if (typeof manifest.description === "string")
128
74
  out.description = manifest.description;
129
- // ---- skills/ ----
130
- //
131
- // An immediate child directory containing a regular `SKILL.md` is one skill. The
132
- // spec says MUST NOT recurse deeper, and that is also what keeps a plugin from
133
- // silently pulling in a skill buried in `scripts/vendor/…`.
134
75
  const skillsDir = await resolveInside(root, "skills");
135
76
  if (!skillsDir) {
136
77
  out.warnings.push("skills/ resolves outside the plugin root — ignored");
@@ -149,7 +90,6 @@ export async function loadPlugin(dir, dataDir) {
149
90
  const sst = await stat(sm).catch(() => null);
150
91
  if (!sst?.isFile())
151
92
  continue;
152
- // the SKILL.md itself must also be contained (a symlinked one could point anywhere)
153
93
  const real = await realpath(sm).catch(() => null);
154
94
  if (!real || !within(root, real)) {
155
95
  out.warnings.push(`skill "${entry.name}": SKILL.md escapes the plugin root — skipped`);
@@ -158,7 +98,6 @@ export async function loadPlugin(dir, dataDir) {
158
98
  out.skills.push(entry.name);
159
99
  }
160
100
  }
161
- // ---- mcp.json ----
162
101
  const mcpPath = await resolveInside(root, "mcp.json");
163
102
  if (mcpPath && (await stat(mcpPath).catch(() => null))?.isFile()) {
164
103
  let cfg;
@@ -185,14 +124,6 @@ export async function loadPlugin(dir, dataDir) {
185
124
  }
186
125
  return out;
187
126
  }
188
- /**
189
- * Turn one `mcp.json` server entry into teapot's `McpServerConfig`.
190
- *
191
- * Returns a STRING when the entry is invalid, because the spec's failure boundary is
192
- * per-server: one bad entry must not reject the plugin. The name is prefixed with the
193
- * plugin name so two plugins can each ship a server called `db` without colliding in
194
- * the shared registry.
195
- */
196
127
  async function pluginServer(name, raw, root, dataDir) {
197
128
  if (typeof raw !== "object" || raw === null)
198
129
  return `mcp server "${name}": not an object`;
@@ -202,7 +133,6 @@ async function pluginServer(name, raw, root, dataDir) {
202
133
  if (typeof s.command !== "string" || !s.command)
203
134
  return `mcp server "${name}": stdio requires a command`;
204
135
  const command = expand(s.command, root, dataDir);
205
- // a plugin-relative command must START with ./ (spec §4.1.4) and stay inside
206
136
  let resolvedCmd = command;
207
137
  if (command.startsWith("./")) {
208
138
  const abs = await resolveInside(root, command);
@@ -216,7 +146,6 @@ async function pluginServer(name, raw, root, dataDir) {
216
146
  return `mcp server "${name}": command escapes the plugin root`;
217
147
  resolvedCmd = abs;
218
148
  }
219
- // cwd: "./x", "${PLUGIN_ROOT}/x" or "${PLUGIN_DATA}/x" — anything else is invalid
220
149
  let cwd;
221
150
  if (s.cwd !== undefined) {
222
151
  if (typeof s.cwd !== "string")
@@ -229,7 +158,6 @@ async function pluginServer(name, raw, root, dataDir) {
229
158
  const abs = path.resolve(expanded);
230
159
  if (!within(base, abs))
231
160
  return `mcp server "${name}": cwd escapes its root`;
232
- // PLUGIN_DATA may not exist yet; the spec says the client manages it
233
161
  if (rootedInData)
234
162
  await mkdir(base, { recursive: true }).catch(() => { });
235
163
  cwd = abs;
@@ -237,7 +165,6 @@ async function pluginServer(name, raw, root, dataDir) {
237
165
  const env = {};
238
166
  if (s.env && typeof s.env === "object" && !Array.isArray(s.env)) {
239
167
  for (const [k, v] of Object.entries(s.env)) {
240
- // the spec forbids these two in env: the client owns them
241
168
  if (k === "PLUGIN_ROOT" || k === "PLUGIN_DATA")
242
169
  return `mcp server "${name}": env may not set ${k}`;
243
170
  if (typeof v === "string")
@@ -278,13 +205,6 @@ async function pluginServer(name, raw, root, dataDir) {
278
205
  }
279
206
  return `mcp server "${name}": unknown type ${JSON.stringify(type)} (expected stdio, streamable-http or sse)`;
280
207
  }
281
- /**
282
- * Load every plugin under a directory of plugins.
283
- *
284
- * A broken plugin is reported and skipped rather than failing the whole collection —
285
- * the operator installed it to get something else working, and a plugin is
286
- * third-party code.
287
- */
288
208
  export async function loadPlugins(dir, dataRoot) {
289
209
  const plugins = [];
290
210
  const errors = [];
@@ -296,7 +216,7 @@ export async function loadPlugins(dir, dataRoot) {
296
216
  .sort();
297
217
  }
298
218
  catch {
299
- return { plugins, errors }; // no plugin dir is the normal case
219
+ return { plugins, errors };
300
220
  }
301
221
  for (const name of entries) {
302
222
  const p = path.join(dir, name);
@@ -1,28 +1,3 @@
1
- /**
2
- * Request headers a provider needs to route and cache a turn.
3
- *
4
- * Lives in its own module because it is a pure string computation and `llm.ts` imports the `openai`
5
- * SDK — which MEASURED +32MB RSS / +6.7MB heap just to be loaded. `model-meta.ts` needs exactly this
6
- * function and nothing else, so importing it from `llm.ts` pulled the whole SDK into the boot path of
7
- * every process (the server, every test file, every script).
8
- */
9
- /**
10
- * Per-endpoint request headers. Empty (harmless no-op) for every provider that
11
- * does not ask for any.
12
- *
13
- * OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
14
- * `HTTP-Referer` is the required identifier that puts teapot's usage on the
15
- * public rankings/analytics; the title names it; `programming-app` files it
16
- * under coding tools.
17
- *
18
- * OpenCode Go (https://opencode.ai/docs/go/#where-can-i-use-it) identifies a
19
- * conversation by `x-opencode-session` and returns HTTP 400 without it —
20
- * "Request is missing x-opencode-session and cannot be routed efficiently" —
21
- * so a turn on that endpoint must name the session it belongs to. The value is
22
- * the session directory name, which is stable for the whole conversation:
23
- * a compaction is a new request but not a new conversation, so it keeps the id,
24
- * while a fork/incarnation switch (`currentSession`) gets its own.
25
- */
26
1
  export function providerHeaders(baseUrl, sessionId) {
27
2
  try {
28
3
  const host = new URL(baseUrl).host;
@@ -33,13 +8,11 @@ export function providerHeaders(baseUrl, sessionId) {
33
8
  "X-OpenRouter-Categories": "programming-app",
34
9
  };
35
10
  }
36
- // scoped to this host so the id cannot follow the operator to another
37
- // provider, and skipped entirely when there is no session to name
38
11
  if (sessionId && /(^|\.)opencode\.ai$/i.test(host))
39
12
  return { "x-opencode-session": sessionId };
40
13
  return {};
41
14
  }
42
15
  catch {
43
- return {}; // unparseable baseUrl — never block a request over metadata
16
+ return {};
44
17
  }
45
18
  }
@@ -1,35 +1,7 @@
1
- /**
2
- * Agent Skills: reusable playbooks the agent can load on demand — and create
3
- * itself, so knowledge accumulates across sessions instead of living only in
4
- * chat history.
5
- *
6
- * A skill is a directory with a SKILL.md:
7
- *
8
- * ---
9
- * name: release-checklist
10
- * description: Steps to cut a release safely
11
- * ---
12
- * (free-form instructions shown to the agent when it loads the skill)
13
- *
14
- * Roots are scanned in priority order; the first skill with a given name wins.
15
- * Workspace skills (<workspace>/skills) beat global ones (~config/skills), so a
16
- * project can override shared defaults.
17
- */
18
1
  import { promises as fs } from "node:fs";
19
2
  import path from "node:path";
20
3
  import { homeDir } from "../home.js";
21
4
  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
- * It must walk every SKILL.md the discovery walk could adopt, not just the
29
- * roots: writing SKILL.md does not bump the parent directory's mtime. Helper
30
- * scripts edited in place ride along with the next skill write (saveSkill
31
- * rewrites SKILL.md), rather than triggering their own rescan.
32
- */
33
5
  export async function skillRootsFingerprint(roots) {
34
6
  const parts = [];
35
7
  for (const root of roots) {
@@ -39,17 +11,14 @@ export async function skillRootsFingerprint(roots) {
39
11
  st = await fs.stat(root.dir);
40
12
  }
41
13
  catch {
42
- parts.push("-"); // root missing → no skills here (same as discoverSkills)
14
+ parts.push("-");
43
15
  continue;
44
16
  }
45
17
  parts.push(`${st.mtimeMs}:${st.size}`);
46
- // same depth bound as the discovery walk, or a NESTED skill's add/edit leaves
47
- // this stamp unchanged and the skill stays stale in list_skills
48
18
  await stampSkillFiles(root.dir, 0, parts);
49
19
  }
50
20
  return parts.join("|");
51
21
  }
52
- /** recursive fingerprint walk — mirrors walkSkills' depth bound */
53
22
  async function stampSkillFiles(dir, depth, parts) {
54
23
  if (depth > MAX_SKILL_DEPTH)
55
24
  return;
@@ -71,7 +40,6 @@ async function stampSkillFiles(dir, depth, parts) {
71
40
  parts.push(`${full}=${s.mtimeMs}:${s.size}`);
72
41
  }
73
42
  catch {
74
- // not a skill dir itself — still a container to descend into
75
43
  }
76
44
  await stampSkillFiles(full, depth + 1, parts);
77
45
  }
@@ -79,21 +47,6 @@ async function stampSkillFiles(dir, depth, parts) {
79
47
  export function isValidSkillName(name) {
80
48
  return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
81
49
  }
82
- /**
83
- * Minimal frontmatter parser for the subset we need: a leading `---` block of
84
- * `key: value` lines. No YAML dependency by design.
85
- *
86
- * It does understand the two YAML shapes common in REAL third-party skills
87
- * (#28), because without them those files are unreadable:
88
- * - FOLDED/LITERAL BLOCK SCALARS (`description: >-` … or `|` …), which is how
89
- * most skill authors write a long description. Reading them naively yields
90
- * the literal ">-" as the description, which silently destroys the one field
91
- * the model matches its task against.
92
- * - QUOTED values, whose surrounding quotes are stripped so a description
93
- * containing a colon survives.
94
- * A key with an empty value (e.g. a nested `metadata:` block) is left out rather
95
- * than recorded as "".
96
- */
97
50
  export function parseSkillMd(text) {
98
51
  const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
99
52
  if (!m)
@@ -106,8 +59,6 @@ export function parseSkillMd(text) {
106
59
  continue;
107
60
  const key = kv[1].toLowerCase();
108
61
  const raw = kv[2].trim();
109
- // folded (>-, >, |-, |) or literal (|, |-) block scalar: the value is the
110
- // following, more-indented lines
111
62
  if (/^[>|][-+]?$/.test(raw)) {
112
63
  const parts = [];
113
64
  let j = i + 1;
@@ -118,7 +69,7 @@ export function parseSkillMd(text) {
118
69
  continue;
119
70
  }
120
71
  if (!/^\s/.test(l))
121
- break; // dedented → block ended
72
+ break;
122
73
  parts.push(l.trim());
123
74
  }
124
75
  i = j - 1;
@@ -128,12 +79,11 @@ export function parseSkillMd(text) {
128
79
  continue;
129
80
  }
130
81
  if (raw === "")
131
- continue; // `metadata:` with a nested block below
82
+ continue;
132
83
  meta[key] = unquote(raw);
133
84
  }
134
85
  return { meta, body: m[2].trim() };
135
86
  }
136
- /** Strip one layer of matching quotes; a quoted value may itself contain `:`. */
137
87
  function unquote(v) {
138
88
  const s = v.trim();
139
89
  if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
@@ -144,21 +94,7 @@ function unquote(v) {
144
94
  }
145
95
  return s;
146
96
  }
147
- /**
148
- * Skill directories owned by OTHER coding harnesses (#28).
149
- *
150
- * SKILL.md is a shared convention, so these are readable as-is — the only
151
- * thing that ever blocked them was our frontmatter parser (folded scalars and
152
- * quoted values), now handled. Paths are the documented native locations,
153
- * plus the cross-tool directories several tools also read.
154
- *
155
- * Priority is deliberately the LOWEST of the user-supplied roots: a project
156
- * should never be shadowed by another tool's copy of the same skill. Missing
157
- * directories are skipped silently, and nothing is read unless it exists.
158
- */
159
97
  export function foreignSkillRoots(workspace) {
160
- // #110: the shared helper also covers HOMEDRIVE/HOMEPATH, which is how
161
- // Windows reports a home when USERPROFILE is absent.
162
98
  const home = homeDir();
163
99
  const out = [];
164
100
  const add = (dir) => {
@@ -166,27 +102,26 @@ export function foreignSkillRoots(workspace) {
166
102
  out.push({ dir, source: "foreign" });
167
103
  };
168
104
  if (workspace) {
169
- // project-local locations other tools read
170
105
  for (const rel of [
171
- path.join(".agents", "skills"), // cross-tool convention
106
+ path.join(".agents", "skills"),
172
107
  path.join(".claude", "skills"),
173
108
  path.join(".codex", "skills"),
174
109
  path.join(".cursor", "skills"),
175
110
  path.join(".gemini", "skills"),
176
- path.join(".github", "skills"), // Copilot CLI + VS Code
111
+ path.join(".github", "skills"),
177
112
  path.join(".opencode", "skills"),
178
113
  ]) {
179
114
  add(path.join(workspace, rel));
180
115
  }
181
116
  }
182
117
  if (home) {
183
- add(path.join(home, ".agents", "skills")); // cross-tool, personal
118
+ add(path.join(home, ".agents", "skills"));
184
119
  for (const rel of [
185
120
  [".claude", "skills"],
186
121
  [".codex", "skills"],
187
122
  [".cursor", "skills"],
188
123
  [".gemini", "skills"],
189
- [".copilot", "skills"], // GitHub Copilot CLI + VS Code share this
124
+ [".copilot", "skills"],
190
125
  [".config", "opencode", "skills"],
191
126
  ]) {
192
127
  add(path.join(home, ...rel));
@@ -194,31 +129,15 @@ export function foreignSkillRoots(workspace) {
194
129
  }
195
130
  return out;
196
131
  }
197
- /**
198
- * How deep to look for nested skills (#27).
199
- *
200
- * Bounded because a skill directory also holds BUNDLED FILES (helper scripts)
201
- * and arbitrary payload, so an unbounded walk is not safe. Depth is not the
202
- * thing that makes a directory a skill — holding a SKILL.md is — so the bound
203
- * only limits how far we look, never what counts.
204
- */
205
132
  const MAX_SKILL_DEPTH = 4;
206
- /** Root dirs in priority order: [0] overrides later ones on name clash. */
207
133
  export async function discoverSkills(roots) {
208
134
  const byName = new Map();
209
- // name -> depth, so a SHALLOWER skill beats a nested namesake (#27)
210
135
  const depthByName = new Map();
211
136
  for (const root of roots) {
212
137
  await walkSkills(root.dir, root.source, 0, byName, depthByName);
213
138
  }
214
139
  return [...byName.values()];
215
140
  }
216
- /**
217
- * Depth-first walk collecting every directory that holds a SKILL.md (#27).
218
- *
219
- * A directory without its own SKILL.md is only a container to descend into, which
220
- * is what keeps bundled helper directories from being reported as skills.
221
- */
222
141
  async function walkSkills(dir, source, depth, byName, depthByName) {
223
142
  if (depth > MAX_SKILL_DEPTH)
224
143
  return;
@@ -227,9 +146,8 @@ async function walkSkills(dir, source, depth, byName, depthByName) {
227
146
  entries = await fs.readdir(dir, { withFileTypes: true });
228
147
  }
229
148
  catch {
230
- return; // root missing → simply no skills there
149
+ return;
231
150
  }
232
- // a SKILL.md sitting directly in `dir` makes `dir` a skill AND a container
233
151
  const here = path.join(dir, SKILL_FILE);
234
152
  let hereText = null;
235
153
  try {
@@ -253,7 +171,6 @@ async function walkSkills(dir, source, depth, byName, depthByName) {
253
171
  .sort();
254
172
  }
255
173
  catch {
256
- /* unreadable dir → no bundled files */
257
174
  }
258
175
  byName.set(name, {
259
176
  name,
@@ -268,15 +185,12 @@ async function walkSkills(dir, source, depth, byName, depthByName) {
268
185
  for (const e of entries) {
269
186
  if (!e.isDirectory() || e.name.startsWith("."))
270
187
  continue;
271
- // a skill can sit at ANY depth (skills/stage1/template/SKILL.md), and
272
- // "stage1" itself has no SKILL.md (#27)
273
188
  await walkSkills(path.join(dir, e.name), source, depth + 1, byName, depthByName);
274
189
  }
275
190
  }
276
191
  export async function readSkillFile(def) {
277
192
  return fs.readFile(def.filePath, "utf8");
278
193
  }
279
- /** Write (or overwrite) a workspace skill; returns the file path written. */
280
194
  export async function saveSkill(workspaceRoot, name, description, content) {
281
195
  const dir = path.join(workspaceRoot, name);
282
196
  await fs.mkdir(dir, { recursive: true });