Skip to content

Commit 43e243f

Browse files
authored
fix: support grouped skill folders
Support grouped skill folders while keeping skill invocation flat via frontmatter names. Includes bounded nested SKILL.md discovery, refresh/watch coverage for grouped folders, plugin symlink containment, and docs for grouped skill organization. Verification: - Node 24 targeted skill discovery and refresh tests passed locally. - Docs checks passed locally and in CI. - Autoreview clean. - Crabbox live OpenAI proof showed nested foo/bar skills listed and visible in the agent system prompt. - CI run 26595118581 passed.
1 parent 4b8c260 commit 43e243f

10 files changed

Lines changed: 1656 additions & 225 deletions

docs/concepts/agent.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ OpenClaw loads skills from these locations (highest precedence first):
6565
- Bundled (shipped with the install)
6666
- Extra skill folders: `skills.load.extraDirs`
6767

68+
Skill roots can contain grouped folders such as
69+
`<workspace>/skills/personal/foo/SKILL.md`; the skill is still exposed by its
70+
flat frontmatter name, for example `foo`.
71+
6872
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
6973

7074
## Runtime boundaries

docs/concepts/system-prompt.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
258258
location (workspace, managed, or bundled). If no skills are eligible, the
259259
Skills section is omitted.
260260

261+
The location can point at a nested skill, such as
262+
`skills/personal/foo/SKILL.md`. Nesting is only organizational; the prompt still
263+
uses the flat skill name from `SKILL.md` frontmatter.
264+
261265
Eligibility includes skill metadata gates, runtime environment/config checks,
262266
and the effective agent skill allowlist when `agents.defaults.skills` or
263267
`agents.list[].skills` is configured.

docs/tools/creating-skills.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
2121
mkdir -p ~/.openclaw/workspace/skills/hello-world
2222
```
2323

24+
You can group skills in subfolders when your library grows:
25+
26+
```bash
27+
mkdir -p ~/.openclaw/workspace/skills/personal/hello-world
28+
```
29+
30+
Group folders are only organizational. The skill is still named by
31+
`SKILL.md` frontmatter, so `name: hello-world` is invoked as
32+
`/hello-world`.
33+
2434
</Step>
2535

2636
<Step title="Write SKILL.md">
@@ -40,7 +50,7 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
4050
```
4151

4252
Use hyphen-case with lowercase letters, digits, and hyphens for the skill
43-
`name`. Keep the folder name and frontmatter `name` aligned.
53+
`name`. Keep the leaf folder name and frontmatter `name` aligned.
4454

4555
</Step>
4656

@@ -52,7 +62,15 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
5262
</Step>
5363

5464
<Step title="Load the skill">
55-
Start a new session so OpenClaw picks up the skill:
65+
Verify the skill loaded:
66+
67+
```bash
68+
openclaw skills list
69+
```
70+
71+
OpenClaw watches nested `SKILL.md` files under skills roots. If the watcher
72+
is disabled or you are continuing an existing session, start a new session
73+
so the model receives the refreshed skills list:
5674

5775
```bash
5876
# From chat
@@ -62,12 +80,6 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
6280
openclaw gateway restart
6381
```
6482

65-
Verify the skill loaded:
66-
67-
```bash
68-
openclaw skills list
69-
```
70-
7183
</Step>
7284

7385
<Step title="Test it">
@@ -134,6 +146,10 @@ Once a basic skill works, these fields help make it reliable and portable:
134146
| Bundled (shipped with OpenClaw) | Low | Global |
135147
| `skills.load.extraDirs` | Lowest | Custom shared folders |
136148

149+
Each skills root can contain direct skill folders such as
150+
`skills/hello-world/SKILL.md` or grouped folders such as
151+
`skills/personal/hello-world/SKILL.md`.
152+
137153
## Related
138154

139155
- [Skills reference](/tools/skills) — loading, precedence, and gating rules

docs/tools/skills.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ OpenClaw loads skills from these sources, **highest precedence first**:
2929

3030
If a skill name conflicts, the highest source wins.
3131

32+
Skill roots can be organized with folders. A skill is discovered when a
33+
`SKILL.md` appears under a configured skills root, so these are both valid:
34+
35+
```text
36+
<workspace>/skills/research/SKILL.md
37+
<workspace>/skills/personal/research/SKILL.md
38+
```
39+
40+
The folder path is only for organization. The skill's visible name, slash
41+
command, and allowlist key come from `SKILL.md` frontmatter `name` (or the skill
42+
directory name when `name` is missing), so a nested skill with `name: research`
43+
is still invoked as `/research`, not `/personal/research`.
44+
3245
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
3346
skill roots. In Codex harness mode, local app-server launches use isolated
3447
per-agent Codex homes, so skills in the operator's personal `~/.codex/skills`
@@ -149,9 +162,11 @@ all local agents unless agent skill allowlists narrow visibility. The separate
149162
`clawhub` CLI also installs into `./skills` under your current working
150163
directory (or falls back to the configured OpenClaw workspace). OpenClaw picks
151164
that up as `<workspace>/skills` on the next session.
152-
Configured skill roots also support one grouping level, such as
153-
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be
154-
kept under a shared folder without broad recursive scanning.
165+
Configured skill roots also support grouped layouts, such as
166+
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be kept
167+
under shared folders without broad recursive scanning. Use flat frontmatter
168+
names when grouping, for example `skills/imported/research/SKILL.md` with
169+
`name: research`.
155170

156171
Git and local directory installs expect a `SKILL.md` at the source root. The
157172
install slug comes from `SKILL.md` frontmatter `name` when it is a valid slug,
@@ -196,6 +211,11 @@ Prefer sandboxed runs for untrusted inputs and risky tools. See
196211
</Warning>
197212

198213
- Workspace, project-agent, and extra-dir skill discovery only accepts skill roots whose resolved realpath stays inside the configured root unless `skills.load.allowSymlinkTargets` explicitly trusts a target root. Bundled skills always stay contained. Managed `~/.openclaw/skills` and personal `~/.agents/skills` roots may contain symlinked skill folders installed by ClawHub or another local skill manager, but every `SKILL.md` realpath must still stay inside its resolved skill directory.
214+
- Nested discovery is bounded. OpenClaw scans grouped skill folders under
215+
skills roots such as `<workspace>/skills`, `<workspace>/.agents/skills`,
216+
`~/.agents/skills`, and `~/.openclaw/skills`, but skips hidden directories,
217+
`node_modules`, oversized `SKILL.md` files, escaped symlinks, and suspiciously
218+
large directory trees.
199219
- Gateway private archive installs are off by default. When explicitly enabled,
200220
they require a committed zip upload containing `SKILL.md` and reuse the same
201221
archive extraction, path traversal, symlink, force, and rollback protections as
@@ -488,6 +508,10 @@ layouts where a skill root contains a symlink, for example
488508
symlinks from local skill managers by default, but the target list is still
489509
matched after realpath resolution and should stay narrow when configured.
490510

511+
The watcher covers nested `SKILL.md` files under grouped skill roots. Adding or
512+
editing `skills/personal/foo/SKILL.md` refreshes the snapshot the same way as
513+
editing `skills/foo/SKILL.md`.
514+
491515
### Remote macOS nodes (Linux gateway)
492516

493517
If the Gateway runs on Linux but a **macOS node** is connected with

src/agents/live-model-dynamic-candidates.test.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../config/types.openclaw.js";
33
import type { Model } from "../llm/types.js";
4+
5+
vi.mock("./agent-model-discovery.js", () => ({
6+
normalizeDiscoveredAgentModel: (value: unknown) => value,
7+
}));
8+
49
import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js";
510

611
const REGISTRY = { find: () => undefined } as never;
12+
const DYNAMIC_PROVIDER = "dynamic-test-provider";
713
type DynamicModelResolver = NonNullable<
814
Parameters<typeof appendPrioritizedDynamicLiveModels>[0]["resolveDynamicModel"]
915
>;
@@ -29,15 +35,15 @@ function model(provider: string, id: string): Model {
2935
describe("appendPrioritizedDynamicLiveModels", () => {
3036
it("materializes prioritized refs from provider dynamic model hooks", async () => {
3137
const resolveDynamicModel: DynamicModelResolver = vi.fn((params) =>
32-
params.context.provider === "opencode-go" && params.context.modelId === "glm-5"
33-
? model("opencode-go", "glm-5")
38+
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
39+
? model(DYNAMIC_PROVIDER, "glm-5")
3440
: undefined,
3541
);
3642
const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined);
3743
const config = {
3844
models: {
3945
providers: {
40-
"opencode-go": {
46+
[DYNAMIC_PROVIDER]: {
4147
api: "openai-completions",
4248
baseUrl: "https://configured.example/v1",
4349
models: [],
@@ -55,56 +61,56 @@ describe("appendPrioritizedDynamicLiveModels", () => {
5561
prepareDynamicModel,
5662
refs: [
5763
{ provider: "anthropic", id: "claude-sonnet-4-6" },
58-
{ provider: "opencode-go", id: "glm-5" },
64+
{ provider: DYNAMIC_PROVIDER, id: "glm-5" },
5965
],
6066
});
6167

6268
expect(result.added.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
63-
"opencode-go/glm-5",
69+
`${DYNAMIC_PROVIDER}/glm-5`,
6470
]);
6571
expect(result.models.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
6672
"anthropic/claude-sonnet-4-6",
67-
"opencode-go/glm-5",
73+
`${DYNAMIC_PROVIDER}/glm-5`,
6874
]);
6975
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
7076
expect(prepareDynamicModel).toHaveBeenCalledWith(
7177
expect.objectContaining({
72-
provider: "opencode-go",
78+
provider: DYNAMIC_PROVIDER,
7379
context: expect.objectContaining({
7480
agentDir: "/tmp/openclaw-agent",
7581
modelId: "glm-5",
7682
modelRegistry: REGISTRY,
77-
provider: "opencode-go",
78-
providerConfig: config.models?.providers?.["opencode-go"],
83+
provider: DYNAMIC_PROVIDER,
84+
providerConfig: config.models?.providers?.[DYNAMIC_PROVIDER],
7985
}),
8086
}),
8187
);
8288
expect(resolveDynamicModel).toHaveBeenCalledTimes(1);
8389
expect(resolveDynamicModel).toHaveBeenCalledWith(
8490
expect.objectContaining({
85-
provider: "opencode-go",
91+
provider: DYNAMIC_PROVIDER,
8692
context: expect.objectContaining({
8793
agentDir: "/tmp/openclaw-agent",
8894
modelId: "glm-5",
8995
modelRegistry: REGISTRY,
90-
provider: "opencode-go",
91-
providerConfig: config.models?.providers?.["opencode-go"],
96+
provider: DYNAMIC_PROVIDER,
97+
providerConfig: config.models?.providers?.[DYNAMIC_PROVIDER],
9298
}),
9399
}),
94100
);
95101
});
96102

97103
it("does not duplicate refs already present in the generated registry", async () => {
98-
const resolveDynamicModel: DynamicModelResolver = vi.fn(() => model("opencode-go", "glm-5"));
104+
const resolveDynamicModel: DynamicModelResolver = vi.fn(() => model(DYNAMIC_PROVIDER, "glm-5"));
99105
const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined);
100106

101107
const result = await appendPrioritizedDynamicLiveModels({
102-
models: [model("opencode-go", "glm-5")],
108+
models: [model(DYNAMIC_PROVIDER, "glm-5")],
103109
agentDir: "/tmp/openclaw-agent",
104110
modelRegistry: REGISTRY,
105111
resolveDynamicModel,
106112
prepareDynamicModel,
107-
refs: [{ provider: "opencode-go", id: "glm-5" }],
113+
refs: [{ provider: DYNAMIC_PROVIDER, id: "glm-5" }],
108114
});
109115

110116
expect(result.added).toEqual([]);

0 commit comments

Comments
 (0)