Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
fe3d501
feat(skills) — extension skills are named by their extension (#9408)
nerdalytics Sep 2, 2026
22402b6
Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/…
nerdalytics Sep 2, 2026
ac418e2
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 2, 2026
0ae1079
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 3, 2026
1b96fdf
Merge branch 'main' into feat/extension-skill-qualified-names
yiliang114 Sep 3, 2026
739459e
fix(skills) — stale settings get honest answers: reason-aware warning…
nerdalytics Sep 3, 2026
af9bdee
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 3, 2026
f58f709
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 3, 2026
c027982
fix(skills) — qualify config-only snapshot expectations; run the lock…
nerdalytics Sep 3, 2026
7bbda72
fix(skills) — honest locks and warnings: trust-gated workspace entrie…
nerdalytics Sep 3, 2026
5810232
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 3, 2026
b90a17e
fix(i18n) — translate the reworded locked-section header for zh and z…
nerdalytics Sep 3, 2026
cd3eeb8
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 4, 2026
a20f1f2
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
efb46ab
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
be7b69c
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
d240f50
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
682a658
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
8be872d
chore: merge main into feat/extension-skill-qualified-names
qwen-code-dev-bot Sep 4, 2026
551f4f6
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
298f3c5
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 4, 2026
e7223d6
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 5, 2026
08a81ef
fix(skills) — advertise registered names, restore six locales, pin li…
nerdalytics Sep 5, 2026
61c91f7
Merge remote-tracking branch 'origin/main' into feat/extension-skill-…
nerdalytics Sep 5, 2026
cc128a1
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 5, 2026
fb6558a
fix(skills) — align mention-context tests with qualified advertisement
nerdalytics Sep 5, 2026
941d876
Merge branch 'feat/extension-skill-qualified-names' of https://github…
nerdalytics Sep 5, 2026
430ab49
Merge remote-tracking branch 'origin/main' into feat/extension-skill-…
nerdalytics Sep 5, 2026
a63ba7f
fix(skills) — entry-scoped grant warnings and authored-name resume re…
nerdalytics Sep 5, 2026
443320c
fix(skills) — name the highest scope in lock labels, per merge preced…
nerdalytics Sep 5, 2026
708f8f2
fix(skills) — correct the R3-3 witness expectation to the entry-namin…
nerdalytics Sep 5, 2026
5b2cfa0
fix(skills) — hard-unblock advice never loops on same-named local skills
nerdalytics Sep 5, 2026
c076a18
fix(skills) — default-off pair warning names only true default-offs a…
nerdalytics Sep 6, 2026
fc0bf77
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 6, 2026
b4aec83
Merge branch 'QwenLM:main' into feat/extension-skill-qualified-names
nerdalytics Sep 7, 2026
aead7ca
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 7, 2026
c05ec5d
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 7, 2026
315017a
fix(skills) — pin the caller's default-off set, and no off-claim over…
nerdalytics Sep 7, 2026
ca53fd5
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 8, 2026
6fe8cff
fix(skills) — the pair warning names its hard blockers, and the quali…
nerdalytics Sep 8, 2026
cc6661e
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 8, 2026
6872fd2
Merge branch 'main' into feat/extension-skill-qualified-names
nerdalytics Sep 8, 2026
b77fdad
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 8, 2026
ab8e10e
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 9, 2026
fc1bfe2
fix(skills) — the daemon toggle stops promising an enable the bare en…
nerdalytics Sep 10, 2026
a28076f
Merge branch 'main' into feat/extension-skill-qualified-names
wenshao Sep 11, 2026
c8fca5f
Merge remote-tracking branch 'origin/main' into feat/extension-skill-…
nerdalytics Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 45 additions & 43 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/users/extension/getting-started-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ Subagents are specialized AI assistants for specific tasks.

After restarting Qwen Code, your custom skills will be available via `/skills` and subagents via `/agents manage`.

An extension skill is registered under its owner's name: the skill above becomes `my-first-extension:code-analyzer`, so you run it as `/my-first-extension:code-analyzer`. `skills.disabled` can block it under either that name or the plain `code-analyzer` you authored; `skills.enabled` opts it back in under the prefixed name only. See [Extension Skills](../features/skills.md#extension-skills).

## Step 6: Add a Custom `QWEN.md`

You can provide persistent context to the model by adding a `QWEN.md` file to your extension. This is useful for giving the model instructions on how to behave or information about your extension's tools. Note that you may not always need this for extensions built to expose commands and prompts.
Expand Down
12 changes: 9 additions & 3 deletions docs/users/extension/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,15 +364,21 @@ Extensions can provide custom skills by placing skill files in a `skills/` subdi

**Example**

An extension named `gcp` with the following structure:

```
.qwen/extensions/my-extension/
.qwen/extensions/gcp/
├── qwen-extension.json
└── skills/
└── pdf-processor/
└── SKILL.md
└── SKILL.md # frontmatter: name: pdf-processor
```

The skill will be available via the `/skills` command when the extension is active.
provides one skill, registered as `gcp:pdf-processor` — the extension's `name`, a colon, then the name the `SKILL.md` authors. Run it with `/gcp:pdf-processor`; `/skills` lists it and labels it with the extension's display name, falling back to its `name` when the manifest declares none.

Unlike the extension's custom commands, which are named after their files (`/deploy` and `/gcs:sync` above, and prefixed only when one collides — see Conflict resolution below), an extension skill always carries its owner: two extensions that both ship a `pdf-processor` give you two skills instead of one shadowing the other. The prefix is added as the skill loads, so the `name` in your `SKILL.md` is never rewritten on disk.

Settings that name skills treat the two spellings asymmetrically: `skills.disabled` blocks a skill under either name, while `skills.enabled` opts it in under the prefixed name only. See [Extension Skills](../features/skills.md#extension-skills).

### Custom subagents

Expand Down
4 changes: 4 additions & 0 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ Commands for managing AI tools and models.
>
> `/workflows`, `/lsp`, and `/trust` are registered only when their feature is enabled — via the user/system-scoped `tools.workflowsEnabled` setting or `QWEN_CODE_ENABLE_WORKFLOWS=1` env var, the `--experimental-lsp` CLI flag, and the `security.folderTrust.enabled` setting respectively. Workspace values for `tools.workflowsEnabled` are ignored. When disabled these commands won't appear and will report an unknown command. Similarly, `/dream` and `/forget` are registered only when managed auto-memory is available; without it they won't appear.

> [!note]
>
> A skill from an installed extension is a slash command too, and its name carries its owner: `/rust:pdf`, not `/pdf`. The bare form is not an alias — if another skill is named `pdf`, `/pdf` runs that skill instead. `slashCommands.disabled` gates such a command under either spelling, so an entry written before the name carried the owner still bites. See [How extension Skills are named](./skills.md#how-extension-skills-are-named).

### 1.5 Built-in Skills

These commands invoke bundled skills that provide specialized workflows.
Expand Down
31 changes: 30 additions & 1 deletion docs/users/features/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ If you want to invoke a Skill explicitly, type it as a slash command using the S

Start typing `/` to autocomplete and browse available Skills alongside their descriptions. The `/skills` command opens the Skills panel, where you can browse, search, toggle, and launch Skills interactively.

`<skill-name>` is always the Skill's registered name. For a Skill from an installed extension that name carries its owner — `rust:pdf`, not `pdf` — so you type `/rust:pdf`. See [How extension Skills are named](#how-extension-skills-are-named).

> **Note:** If you previously ran a Skill with `/skills <skill-name>`, that syntax now just opens the Skills panel and ignores the trailing argument. Use `/<skill-name>` to run a Skill directly.

### Benefits
Expand Down Expand Up @@ -133,7 +135,7 @@ Show concrete examples of using this Skill.

Qwen Code currently validates that:

- `name` is a non-empty string matching `/^[\p{L}\p{N}_:.-]+$/u` — Unicode letters and digits (CJK / Cyrillic / accented Latin all OK), plus `_`, `:`, `.`, `-`. Whitespace, slashes, brackets and other structurally unsafe characters are rejected at parse time.
- `name` is a non-empty string matching `/^[\p{L}\p{N}_:.-]+$/u` — Unicode letters and digits (CJK / Cyrillic / accented Latin all OK), plus `_`, `:`, `.`, `-`. Whitespace, slashes, brackets and other structurally unsafe characters are rejected at parse time. The admitted `:` is what lets a Skill registered by an extension (`rust:pdf`) and an author who chooses a colon themselves (`rust:chat`, written inside the `rust` extension) share one pattern, so a colon in a registered name is not proof of an owner — see [How extension Skills are named](#how-extension-skills-are-named).
- `description` is a non-empty string
- `priority` is optional. When present, it must be a finite number. Higher values sort earlier in the `/skills` listing only — slash-command completion (typing `/`) and the `/help` custom commands view stay alphabetical, so a high-priority Skill never reorders built-in commands. Omitted or invalid values are treated as unset, which behaves like `0`.

Expand Down Expand Up @@ -274,6 +276,33 @@ Extension skills are automatically discovered and loaded when the extension is i

To see which extensions provide skills, check the extension's `qwen-extension.json` file for a `skills` field.

#### How extension Skills are named

Qwen Code registers a Skill from an installed extension as `<extensionName>:<name>`, where `<extensionName>` is the `name` field of that extension's `qwen-extension.json` and `<name>` is the Skill's own frontmatter `name`. A Skill named `pdf` in the `rust` extension is registered as `rust:pdf`.

The prefix is added while the Skill is loaded, not written into the file: your `SKILL.md` keeps the name you authored, and Qwen Code never recovers the authored name by splitting the registered one apart (an author may legitimately write `rust:chat` inside `rust`). Only extension Skills are prefixed — personal, project, and bundled Skills keep the single spelling you authored.

Use the registered name everywhere you refer to the Skill:

- Invoke it as `/rust:pdf`. The bare `/pdf` is not an alias — the extension's Skill is reachable only under its registered name.
- The model calls it as `Skill { skill: "rust:pdf" }`, the same name it reads in `<available_skills>`.
- Two extensions that each ship a Skill named `pdf` give you two Skills (`rust:pdf` and `docs-suite:pdf`) rather than one winning and one disappearing.

The surfaces where you read and choose Skills also name the owner: the Skills panel (including the rows a setting has locked), the read-only listing a bare `/skills` prints outside the interactive UI (ACP and other non-interactive modes — interactively the command opens the panel), and the badge in the `/` command palette, which reads `[Extension: Rust]` rather than a bare `[Extension]`. Those labels prefer the extension's `displayName` and fall back to its `name` when it declares none.

#### Extension Skills and the `skills.*` settings

`skills.disabled`, `skills.defaultDisabled`, and `slashCommands.disabled` match an extension Skill under **either** spelling, so a `skills.disabled: ["pdf"]` you wrote before the prefix existed still hides `rust:pdf`. A restriction can only remove capability, so renaming a Skill is not allowed to lift one.

`skills.enabled` is the exception, and the one visible change for an existing settings file: it grants capability, so it matches the registered name only. `skills.enabled: ["pdf"]` no longer opts an extension's `pdf` in on its own — write `skills.enabled: ["rust:pdf"]`. The one bare pair that keeps working is a pre-prefix opt-in sitting in `skills.defaultDisabled` with the same spelling: cancellation compares the entries themselves, so `defaultDisabled: ["pdf"]` + `enabled: ["pdf"]` cancels the entry — the skill then ends up enabled per the enablement stored for this workspace, else the extension's own default; for a default-off skill, write `rust:pdf` in `skills.enabled`.

Toggling a Skill in the Skills panel writes the registered name and removes only that entry, so enabling `rust:pdf` leaves a legacy `disabled: ["pdf"]` untouched. When that legacy entry sits in a higher scope — system defaults, user, or system settings — the panel says so and locks the row, naming the scope to edit rather than offering a toggle that cannot move it. A legacy entry in this workspace's own settings locks the row the same way, naming the entry and its scope (`skills.disabled 'pdf' (Workspace)` or `skills.defaultDisabled 'pdf' (Workspace)`) so you know which list in which file to edit.

Two limits worth knowing:

- Cross-level precedence is unchanged and still compares registered names exactly (`project` > `user` > `extension` > `bundled`), so a personal or project Skill you author as `rust:pdf` outranks the extension's `pdf`. Bare-name collisions between a personal or project Skill and a bundled Skill are likewise still settled by that precedence, not by the prefix. A Skill that collides with a custom command is not — on the slash surface the last loader wins, and custom commands load after Skills, so `/pdf` runs the custom command while the Skill stays available to the model.
- Skill names are also used as filenames: the file a Skill reads its invocation arguments from replaces every character outside `[A-Za-z0-9._-]` with `_`, so an extension Skill registered as `rust:pdf` and a personal or project Skill authored `rust_pdf` both resolve to `qwen-skill-args-rust_pdf.txt` and share one arguments file. (The prefix rarely collides with itself — `rust:rust_pdf` becomes `rust_rust_pdf` — but extension names may contain `_`, so `rust_pdf:x` and `rust:pdf_x` fold to the same filename.) Non-ASCII letters fold the same way, so an authored `café` and an authored `caf_` land on `caf_` too — a limitation that predates the prefix, which only makes it easier to hit. Avoid a Skill name that is another name with `:` turned into `_`.

To view available Skills, ask Qwen Code directly:

```text
Expand Down
61 changes: 52 additions & 9 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,27 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
this.code = code;
}
},
// The real helper: `isInactiveExtensionSkill` resolves a registry-qualified
// skill back to the authored spelling the manifest keys on through it. A
// mock that omits the export does not fail to compile — it throws at the
// call, and `buildWorkspaceSkillsStatus` swallows that into an error cell,
// so the whole skills list degrades to empty.
authoredSkillName: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).authoredSkillName,
// `buildWorkspaceSkillsStatus` de-duplicates the inactive-extension manifest
// against the registry rows by qualifying the authored name. Same failure
// mode as above: an omitted export throws at the call and empties the list.
qualifySkillName: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).qualifySkillName,
// `lookupSkillDisablement` resolves which settings entry blocks a skill
// through it, so the ACP skill rows need the shipped normalization too.
// Same failure mode as above: an omitted export throws at the call and
// empties the list.
skillRestrictionNames: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).skillRestrictionNames,
getMCPDiscoveryState: vi.fn().mockReturnValue('completed'),
getMCPServerStatus: vi.fn().mockReturnValue('connected'),
MCPServerConfig: vi.fn().mockImplementation((...args: unknown[]) => ({
Expand Down Expand Up @@ -7575,7 +7596,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
filePath: '/disabled/SKILL.md',
},
{
name: 'gsd-audit-uat',
// Post-`SkillManager` shape: `name` is the registry identity and the
// authored spelling rides alongside, because registration qualifies
// extension skills unconditionally.
name: 'gsd-core:gsd-audit-uat',
authoredName: 'gsd-audit-uat',
description: 'Cross-phase audit',
level: 'extension',
extensionName: 'gsd-core',
Expand All @@ -7585,7 +7610,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
filePath: '/ext/gsd-core/skills/gsd-audit-uat/SKILL.md',
},
{
name: 'gsd-display-stale',
name: 'gsd-core:gsd-display-stale',
authoredName: 'gsd-display-stale',
description: 'Display-name stale extension skill',
level: 'extension',
extensionName: 'gsd-core',
Expand Down Expand Up @@ -8045,7 +8071,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.objectContaining({
kind: 'skill',
status: 'disabled',
name: 'gsd-audit-uat',
name: 'gsd-core:gsd-audit-uat',
description: 'Cross-phase audit',
level: 'extension',
extensionName: 'gsd-core',
Expand All @@ -8055,7 +8081,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.objectContaining({
kind: 'skill',
status: 'disabled',
name: 'gsd-display-stale',
name: 'gsd-core:gsd-display-stale',
description: 'Display-name stale extension skill',
level: 'extension',
extensionName: 'gsd-core',
Expand All @@ -8066,7 +8092,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.objectContaining({
kind: 'skill',
status: 'disabled',
name: 'gsd-config-only',
name: 'gsd-core:gsd-config-only',
description: 'Config-only extension skill',
level: 'extension',
extensionName: 'gsd-core',
Expand All @@ -8077,7 +8103,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
expect.objectContaining({
kind: 'skill',
status: 'disabled',
name: 'gsd-config-only',
name: 'gsd-tools:gsd-config-only',
description: 'Colliding config-only extension skill',
level: 'extension',
extensionName: 'gsd-tools',
Expand All @@ -8087,14 +8113,31 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
}),
]),
});
// These two skills are in the cached registry *and* in the deactivated
// `gsd-core` manifest, so they must appear once. The registry row is keyed
// by the qualified name and the manifest row by the authored one, so the
// duplicate this guards against lands under a different spelling — count
// both, or the assertion passes on a list that emitted the skill twice.
expect(
skills.skills.filter((skill) => skill.name === 'gsd-audit-uat'),
skills.skills.filter(
(skill) =>
skill.name === 'gsd-core:gsd-audit-uat' ||
skill.name === 'gsd-audit-uat',
),
).toHaveLength(1);
expect(
skills.skills.filter((skill) => skill.name === 'gsd-display-stale'),
skills.skills.filter(
(skill) =>
skill.name === 'gsd-core:gsd-display-stale' ||
skill.name === 'gsd-display-stale',
),
).toHaveLength(1);
expect(
skills.skills.filter((skill) => skill.name === 'gsd-config-only'),
skills.skills.filter(
(skill) =>
skill.name === 'gsd-core:gsd-config-only' ||
skill.name === 'gsd-tools:gsd-config-only',
),
).toHaveLength(2);
expect(skillsAgain).toEqual(skills);
expect(getCachedSkills).toHaveBeenCalledTimes(2);
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import {
extractAndStripMeta,
listWorkflowSnapshots,
type TurnResultRecordPayload,
qualifySkillName,
sessionIdContext,
} from '@qwen-code/qwen-code-core';
import { randomUUID, timingSafeEqual } from 'node:crypto';
Expand Down Expand Up @@ -7232,13 +7233,26 @@ class QwenAgent implements Agent {
if (extension.isActive) continue;
for (const skill of extension.skills ?? []) {
const extensionName = extension.name;
const key = `extension:${extensionName}:${skill.name}`;
// The registry rows above key on the qualified name; the manifest
// still holds the authored spelling. Qualify it here or the two keys
// never meet, and a stale registry row plus its manifest entry emit
// the same skill twice under two names.
const key = `extension:${extensionName}:${qualifySkillName(
extensionName,
skill.name,
)}`;
if (skillsByKey.has(key)) continue;
skillsByKey.set(
key,
mapSkillConfigToStatus(
{
...skill,
// Carry the qualified name like the registry rows do: a
// surface that persists this row's name verbatim (the web
// shell's Enable toggle) would otherwise write a bare entry
// that can never grant once the extension activates.
name: qualifySkillName(extensionName, skill.name),
authoredName: skill.name,
level: 'extension',
extensionName,
extensionDisplayName: extension.displayName,
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/acp-integration/extension-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ describe('extension skill activity helpers', () => {
);
});

it('matches a registry-qualified skill through its authored name', () => {
// The registry names an extension skill `ext:audit` while the manifest —
// what `inactiveExtensionSkillRefs` reads — keeps `audit`. Without the
// authored-name fallback the lookup misses, which reads as "not inactive"
// and would expose an inactive extension's skill.
const refs = inactiveExtensionSkillRefs(
configWithExtensions([
extension({
name: 'ext',
isActive: false,
skills: [extensionSkill('audit')],
}),
]),
);

expect(
isInactiveExtensionSkill(
{
name: 'ext:audit',
authoredName: 'audit',
level: 'extension',
extensionName: 'ext',
},
refs,
),
).toBe(true);
});

it('ignores skills from active extensions', () => {
const refs = inactiveExtensionSkillRefs(
configWithExtensions([
Expand All @@ -101,7 +129,7 @@ describe('extension skill activity helpers', () => {
);
});

it('collects inactive extension skill names for commands without extensionName', () => {
it('collects inactive extension skill registry names', () => {
const names = inactiveExtensionSkillNames(
configWithExtensions([
extension({
Expand All @@ -117,7 +145,7 @@ describe('extension skill activity helpers', () => {
]),
);

expect(names).toEqual(new Set(['audit']));
expect(names).toEqual(new Set(['inactive-ext:audit']));
});

it('ignores non-extension skills', () => {
Expand Down
Loading
Loading