Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f46bb14
feat(skills): add disabled skill levels
zhangxy-zju Jul 29, 2026
b8927e7
Merge branch 'main' into feat/disable-bundled-skills-8054
qwen-code-dev-bot Jul 29, 2026
4a57eb6
Merge branch 'main' into feat/disable-bundled-skills-8054
wenshao Jul 29, 2026
d762df4
Merge branch 'main' into feat/disable-bundled-skills-8054
qwen-code-dev-bot Jul 29, 2026
136cbbf
Merge branch 'main' into feat/disable-bundled-skills-8054
qwen-code-dev-bot Jul 30, 2026
178a961
Merge branch 'main' into feat/disable-bundled-skills-8054
zhangxy-zju Jul 30, 2026
8b21844
Merge branch 'main' into feat/disable-bundled-skills-8054
qwen-code-dev-bot Jul 30, 2026
51f513f
Merge branch 'main' into feat/disable-bundled-skills-8054
wenshao Jul 30, 2026
8884fec
fix(core): guard getDisabledSkillLevels for partial Config shims (#8057)
Jul 30, 2026
24f102e
test(cli): cover disabledLevels safe/bare guard and daemon wiring (#8…
Jul 30, 2026
a4d5321
fix(cli): guard daemon disabledLevels for safe mode consistency (#8057)
Jul 30, 2026
268e3e0
Merge branch 'main' into feat/disable-bundled-skills-8054
zhangxy-zju Jul 30, 2026
3b575a6
Merge remote-tracking branch 'origin/main' into feat/disable-bundled-…
zhangxy-zju Jul 30, 2026
0667e66
Merge branch 'main' into feat/disable-bundled-skills-8054
zhangxy-zju Jul 31, 2026
f4670a6
Merge branch 'main' into feat/disable-bundled-skills-8054
zhangxy-zju Jul 31, 2026
ba09ffd
test(core): add regression guard for missing getDisabledSkillLevels (…
qwen-code-ci-bot Jul 31, 2026
d5ee013
Merge branch 'main' into feat/disable-bundled-skills-8054
zhangxy-zju Jul 31, 2026
fabe6e7
test(core): pin partial config skill discovery (#8057)
zhangxy-zju Jul 31, 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
11 changes: 6 additions & 5 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,11 +497,12 @@ execute when typed.

Controls which [Skills](../features/skills) are exposed to the model.

| Setting | Type | Description | Default |
| ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `skills.disabled` | array of strings | Hard-disabled skill names. Matched case-insensitively and **merged as a union** across settings scopes, so project settings cannot override a user or system entry. Hidden skills do not appear in `<available_skills>` or as `/<name>` slash commands. | `undefined` |
| `skills.defaultDisabled` | array of strings | Skill names that start disabled but can be opted into through `skills.enabled`. Matched case-insensitively and merged as a union across settings scopes. | `undefined` |
| `skills.enabled` | array of strings | Explicit opt-ins that override matching `skills.defaultDisabled` entries. Matched case-insensitively and merged as a union across settings scopes. This setting cannot override `skills.disabled`. | `undefined` |
| Setting | Type | Description | Default |
| ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `skills.disabledLevels` | array of strings | Skill discovery levels to skip entirely. Supported values are `project`, `user`, `extension`, and `bundled`. Merged as a union across settings scopes. Use `["bundled"]` to hide every bundled skill while retaining host-provided skills. Note: `skills.directories` entries are discovered at the `user` level, so `["user"]` hides those too. | `undefined` |
| `skills.disabled` | array of strings | Hard-disabled skill names. Matched case-insensitively and **merged as a union** across settings scopes, so project settings cannot override a user or system entry. Hidden skills do not appear in `<available_skills>` or as `/<name>` slash commands. | `undefined` |
| `skills.defaultDisabled` | array of strings | Skill names that start disabled but can be opted into through `skills.enabled`. Matched case-insensitively and merged as a union across settings scopes. | `undefined` |
| `skills.enabled` | array of strings | Explicit opt-ins that override matching `skills.defaultDisabled` entries. Matched case-insensitively and merged as a union across settings scopes. This setting cannot override `skills.disabled` or re-enable skills from a `skills.disabledLevels`-excluded level. | `undefined` |

The precedence is `skills.disabled` > `skills.enabled` > `skills.defaultDisabled`. For example, a user can put a skill in `defaultDisabled` and a project can add the same name to `enabled`; a hard `disabled` entry at any scope still wins.

Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4769,3 +4769,58 @@ describe('loadCliConfig skills.directories', () => {
expect(config.getCustomSkillDirs()).toEqual([]);
});
});

describe('loadCliConfig skills.disabledLevels', () => {
beforeEach(() => {
process.argv = ['node', 'script.js'];
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
Comment thread
qwen-code-dev-bot marked this conversation as resolved.

afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('passes valid disabled skill levels to core and ignores invalid values', async () => {
const argv = await parseArguments();
const settings: Settings = {
skills: {
disabledLevels: ['bundled', 'invalid', 42 as unknown as string, 'user'],
},
};

const config = await loadCliConfig(settings, argv);

expect(config.getDisabledSkillLevels()).toEqual(
new Set(['bundled', 'user']),
);
});

it('keeps every skill level enabled by default', async () => {
const argv = await parseArguments();

const config = await loadCliConfig({}, argv);

expect(config.getDisabledSkillLevels()).toEqual(new Set());
});

it('ignores skills.disabledLevels in safe mode', async () => {
process.argv = ['node', 'script.js', '--safe-mode'];
const argv = await parseArguments();
const settings: Settings = { skills: { disabledLevels: ['bundled'] } };

const config = await loadCliConfig(settings, argv);

expect(config.getDisabledSkillLevels()).toEqual(new Set());
});

it('ignores skills.disabledLevels in bare mode', async () => {
process.argv = ['node', 'script.js', '--bare'];
const argv = await parseArguments();
const settings: Settings = { skills: { disabledLevels: ['bundled'] } };

const config = await loadCliConfig(settings, argv);

expect(config.getDisabledSkillLevels()).toEqual(new Set());
});
});
16 changes: 16 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
SchemaValidator,
type ConfigParameters,
type MCPServerConfig,
type SkillLevel,
type WebSearchSettings,
MAX_SUBAGENT_DEPTH_LIMIT,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -113,6 +114,17 @@ const VALID_APPROVAL_MODE_VALUES = [
'yolo',
] as const;

const SKILL_LEVELS: readonly SkillLevel[] = [
'project',
'user',
'extension',
'bundled',
];
Comment thread
zhangxy-zju marked this conversation as resolved.

function isSkillLevel(value: unknown): value is SkillLevel {
return SKILL_LEVELS.includes(value as SkillLevel);
}

function formatApprovalModeError(value: string): Error {
return new Error(
`Invalid approval mode: ${value}. Valid values are: ${VALID_APPROVAL_MODE_VALUES.join(
Expand Down Expand Up @@ -2068,6 +2080,10 @@ export async function loadCliConfig(
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
disabledSkillNamesProvider:
bareMode || safeMode ? undefined : disabledSkillNamesProvider,
disabledSkillLevels:
bareMode || safeMode || !Array.isArray(settings.skills?.disabledLevels)
? undefined
: settings.skills.disabledLevels.filter(isSkillLevel),
Comment thread
zhangxy-zju marked this conversation as resolved.
customSkillDirs:
bareMode || safeMode
? undefined
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@qwen-code/qwen-code-core';
import {
getSettingsSchema,
MergeStrategy,
type SettingDefinition,
type Settings,
type SettingsSchema,
Expand Down Expand Up @@ -352,6 +353,22 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().mcp.requiresRestart).toBe(true);
});

it('defines disabled skill levels as a restart-required union setting', () => {
const disabledLevels =
getSettingsSchema().skills.properties.disabledLevels;

expect(disabledLevels.type).toBe('array');
expect(disabledLevels.default).toBeUndefined();
expect(disabledLevels.requiresRestart).toBe(true);
expect(disabledLevels.mergeStrategy).toBe(MergeStrategy.UNION);
expect(disabledLevels.items?.enum).toEqual([
'project',
'user',
'extension',
'bundled',
]);
});

it('should have consistent default values for boolean settings', () => {
const checkBooleanDefaults = (schema: SettingsSchema) => {
Object.entries(schema).forEach(([, definition]) => {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,23 @@ const SETTINGS_SCHEMA = {
'the model.',
showInDialog: false,
properties: {
disabledLevels: {
type: 'array',
label: 'Disabled Skill Levels',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Skill discovery levels to skip entirely. Supported levels are ' +
'project, user, extension, and bundled. UNION-merged across settings ' +
'scopes.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
items: {
type: 'string',
enum: ['project', 'user', 'extension', 'bundled'],
},
},
disabled: {
type: 'array',
label: 'Disabled Skills',
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/serve/workspace-skills-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,60 @@ describe('createWorkspaceSkillsStatusProvider', () => {
}
});

it('hides skills from disabled levels in workspace settings', async () => {
// No listSkills mock: this exercises the real daemon wiring — the
// settings.merged.skills?.disabledLevels read, VALID_SKILL_LEVELS
// filtering, and the getDisabledSkillLevels shim method that the prior
// daemon regression broke.
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-skills-disabled-levels-'),
);
await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true });
await fsp.writeFile(
path.join(workspace, '.qwen', 'settings.json'),
JSON.stringify({ skills: { disabledLevels: ['bundled'] } }),
);
try {
const provider = createWorkspaceSkillsStatusProvider();

const status = await provider(workspace);

expect(status.initialized).toBe(true);
expect(status.skills.find((s) => s.level === 'bundled')).toBeUndefined();
expect(status.skills.find((s) => s.name === 'review')).toBeUndefined();
} finally {
await fsp.rm(workspace, { recursive: true, force: true });
}
});

it('ignores disabledLevels in safe mode (matches CLI child session)', async () => {
const workspace = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-skills-safe-levels-'),
);
await fsp.mkdir(path.join(workspace, '.qwen'), { recursive: true });
await fsp.writeFile(
path.join(workspace, '.qwen', 'settings.json'),
JSON.stringify({ skills: { disabledLevels: ['bundled'] } }),
);
const saved = process.env['QWEN_CODE_SAFE_MODE'];
process.env['QWEN_CODE_SAFE_MODE'] = '1';
try {
const provider = createWorkspaceSkillsStatusProvider();

const status = await provider(workspace);

expect(status.initialized).toBe(true);
expect(status.skills.find((s) => s.name === 'review')).toBeDefined();
} finally {
if (saved === undefined) {
delete process.env['QWEN_CODE_SAFE_MODE'];
} else {
process.env['QWEN_CODE_SAFE_MODE'] = saved;
}
await fsp.rm(workspace, { recursive: true, force: true });
}
});

it('reuses one SkillManager per workspace across calls', async () => {
const listSpy = vi.spyOn(SkillManager.prototype, 'listSkills');
const provider = createWorkspaceSkillsStatusProvider();
Expand Down
45 changes: 35 additions & 10 deletions packages/cli/src/serve/workspace-skills-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
*/

import { SkillManager, isSafeModeEnv } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import type { Config, SkillLevel } from '@qwen-code/qwen-code-core';
import type { ServeWorkspaceSkillsStatus } from '@qwen-code/acp-bridge/status';
import { STATUS_SCHEMA_VERSION } from '@qwen-code/acp-bridge/status';
import { loadSettings } from '../config/settings.js';
Expand All @@ -48,6 +48,13 @@ export interface WorkspaceSkillsStatusProviderOptions {
workspaceTrusted?: boolean;
}

const VALID_SKILL_LEVELS: ReadonlySet<string> = new Set<SkillLevel>([
'project',
'user',
'extension',
'bundled',
]);

/**
* The `Config` surface `SkillManager.listSkills()` actually reads. Declaring it
* as a `Pick` (rather than casting an inline object literal) type-checks the
Expand All @@ -59,7 +66,11 @@ export interface WorkspaceSkillsStatusProviderOptions {
*/
type SkillManagerConfigShim = Pick<
Config,
'isSafeMode' | 'getBareMode' | 'getProjectRoot' | 'getActiveExtensions'
| 'isSafeMode'
| 'getBareMode'
| 'getProjectRoot'
| 'getActiveExtensions'
| 'getDisabledSkillLevels'
>;

export function createWorkspaceSkillsStatusProvider(
Expand Down Expand Up @@ -88,8 +99,28 @@ async function buildWorkspaceSkillsStatus(
workspaceTrusted: boolean,
): Promise<ServeWorkspaceSkillsStatus> {
try {
const settings = loadSettings(workspaceCwd, {
consumeCorruptionEnvVars: false,
skipLoadEnvironment: !workspaceTrusted,
skipWorkspaceSettings: !workspaceTrusted,
workspaceTrusted,
});
let skillManager = managers.get(workspaceCwd);
if (!skillManager) {
// Mirror the CLI guard in loadCliConfig: safe mode nullifies
// disabledSkillLevels so the child session loads all bundled skills.
const rawLevels =
!workspaceTrusted || isSafeModeEnv()
? undefined
: settings.merged.skills?.disabledLevels;
const disabledLevels = new Set<SkillLevel>(
Array.isArray(rawLevels)
? rawLevels.filter(
(v): v is SkillLevel =>
typeof v === 'string' && VALID_SKILL_LEVELS.has(v),
)
: [],
);
const shim: SkillManagerConfigShim = {
// Honor the safe-mode env the same way `Config` does when no explicit
// flag is passed, so an operator running in safe mode gets the same
Expand All @@ -102,18 +133,12 @@ async function buildWorkspaceSkillsStatus(
// Extension skills need active-extension context that only the child
// has; omit them here and let the session snapshot surface them.
getActiveExtensions: () => [],
getDisabledSkillLevels: () => disabledLevels,
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
};
skillManager = new SkillManager(shim as Config);
managers.set(workspaceCwd, skillManager);
}
const disablements = resolveSkillSettings(
loadSettings(workspaceCwd, {
consumeCorruptionEnvVars: false,
skipLoadEnvironment: !workspaceTrusted,
skipWorkspaceSettings: !workspaceTrusted,
workspaceTrusted,
}),
).disablements;
const disablements = resolveSkillSettings(settings).disablements;
const skills = await skillManager.listSkills();
return {
v: STATUS_SCHEMA_VERSION,
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { InputFormat, OutputFormat } from '../output/types.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { ResourceRegistry } from '../resources/resource-registry.js';
import { SkillManager } from '../skills/skill-manager.js';
import type { SkillLevel } from '../skills/types.js';
import { PermissionManager } from '../permissions/permission-manager.js';
import {
type AutoModeDenialState,
Expand Down Expand Up @@ -961,6 +962,11 @@ export interface ConfigParameters {
* Names returned must be lower-cased; consumers compare case-insensitively.
*/
disabledSkillNamesProvider?: () => ReadonlySet<string>;
/**
* Skill discovery levels that should not be loaded. Sourced from
* `settings.skills.disabledLevels`.
*/
disabledSkillLevels?: readonly SkillLevel[];
/**
* Additional directories to scan for skills (SKILL.md files).
* Sourced from `settings.skills.directories`. Paths are raw
Expand Down Expand Up @@ -1778,6 +1784,7 @@ export class Config {
private readonly disabledSkillNamesProvider:
| (() => ReadonlySet<string>)
| null;
private readonly disabledSkillLevels: ReadonlySet<SkillLevel>;
private readonly customSkillDirs: readonly string[];
// `disabledTools` is set at construction
// time but can be re-synced by the daemon mutation surface
Expand Down Expand Up @@ -2091,6 +2098,7 @@ export class Config {
...(params.disabledSlashCommands ?? []),
]);
this.disabledSkillNamesProvider = params.disabledSkillNamesProvider ?? null;
this.disabledSkillLevels = new Set(params.disabledSkillLevels ?? []);
this.customSkillDirs = Object.freeze([...(params.customSkillDirs ?? [])]);
this.disabledTools = new Set(params.disabledTools ?? []);
this.visibleTools = new Set(
Expand Down Expand Up @@ -5001,6 +5009,14 @@ export class Config {
return this.disabledSkillNamesProvider?.() ?? EMPTY_DISABLED_SKILL_NAMES;
}

/**
* Returns skill discovery levels excluded through
* `settings.skills.disabledLevels`.
*/
getDisabledSkillLevels(): ReadonlySet<SkillLevel> {
return this.disabledSkillLevels;
}
Comment thread
qwen-code-dev-bot marked this conversation as resolved.

/**
* Returns additional skill directories from `settings.skills.directories`.
* Paths are raw (unexpanded); consumers must handle `~` expansion
Expand Down
Loading
Loading