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.
- package/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/agent/plugins.js
CHANGED
|
@@ -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 };
|
|
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 {};
|
|
16
|
+
return {};
|
|
44
17
|
}
|
|
45
18
|
}
|
package/dist/agent/skills.js
CHANGED
|
@@ -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("-");
|
|
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;
|
|
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;
|
|
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"),
|
|
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"),
|
|
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"));
|
|
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"],
|
|
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;
|
|
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 });
|