diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 4ca7c4ee080..3b8d416de9b 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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 `` or as `/` 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 `` or as `/` 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. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e9e4367956c..3333350fe50 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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'); + }); + + 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()); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9fceff2488f..0a2c1d280aa 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,6 +38,7 @@ import { SchemaValidator, type ConfigParameters, type MCPServerConfig, + type SkillLevel, type WebSearchSettings, MAX_SUBAGENT_DEPTH_LIMIT, } from '@qwen-code/qwen-code-core'; @@ -113,6 +114,17 @@ const VALID_APPROVAL_MODE_VALUES = [ 'yolo', ] as const; +const SKILL_LEVELS: readonly SkillLevel[] = [ + 'project', + 'user', + 'extension', + 'bundled', +]; + +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( @@ -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), customSkillDirs: bareMode || safeMode ? undefined diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 9904c6a5a25..a8eb19f1ddc 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -12,6 +12,7 @@ import { } from '@qwen-code/qwen-code-core'; import { getSettingsSchema, + MergeStrategy, type SettingDefinition, type Settings, type SettingsSchema, @@ -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]) => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ba6b736f6bb..b2f9088cdbe 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/serve/workspace-skills-status.test.ts b/packages/cli/src/serve/workspace-skills-status.test.ts index 2c036f3bbb9..c4d09f04af8 100644 --- a/packages/cli/src/serve/workspace-skills-status.test.ts +++ b/packages/cli/src/serve/workspace-skills-status.test.ts @@ -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(); diff --git a/packages/cli/src/serve/workspace-skills-status.ts b/packages/cli/src/serve/workspace-skills-status.ts index 663847b8008..e9b08268572 100644 --- a/packages/cli/src/serve/workspace-skills-status.ts +++ b/packages/cli/src/serve/workspace-skills-status.ts @@ -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'; @@ -48,6 +48,13 @@ export interface WorkspaceSkillsStatusProviderOptions { workspaceTrusted?: boolean; } +const VALID_SKILL_LEVELS: ReadonlySet = new Set([ + '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 @@ -59,7 +66,11 @@ export interface WorkspaceSkillsStatusProviderOptions { */ type SkillManagerConfigShim = Pick< Config, - 'isSafeMode' | 'getBareMode' | 'getProjectRoot' | 'getActiveExtensions' + | 'isSafeMode' + | 'getBareMode' + | 'getProjectRoot' + | 'getActiveExtensions' + | 'getDisabledSkillLevels' >; export function createWorkspaceSkillsStatusProvider( @@ -88,8 +99,28 @@ async function buildWorkspaceSkillsStatus( workspaceTrusted: boolean, ): Promise { 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( + 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 @@ -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, }; 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, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 06ecf2eb949..857a1bdb8ee 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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, @@ -961,6 +962,11 @@ export interface ConfigParameters { * Names returned must be lower-cased; consumers compare case-insensitively. */ disabledSkillNamesProvider?: () => ReadonlySet; + /** + * 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 @@ -1778,6 +1784,7 @@ export class Config { private readonly disabledSkillNamesProvider: | (() => ReadonlySet) | null; + private readonly disabledSkillLevels: ReadonlySet; private readonly customSkillDirs: readonly string[]; // `disabledTools` is set at construction // time but can be re-synced by the daemon mutation surface @@ -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( @@ -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 { + return this.disabledSkillLevels; + } + /** * Returns additional skill directories from `settings.skills.directories`. * Paths are raw (unexpanded); consumers must handle `~` expansion diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index f90d3763ef4..6333f122d0f 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -1147,6 +1147,51 @@ Review content`; expect(simplifySkill!.level).toBe('bundled'); }); + it('should skip disabled skill levels without scanning them', async () => { + const disabledConfig = makeFakeConfig({ + disabledSkillLevels: ['bundled'], + }); + vi.spyOn(disabledConfig, 'getProjectRoot').mockReturnValue( + '/test/project', + ); + const disabledManager = new SkillManager(disabledConfig); + mockReaddirForLevels(new Set(['project', 'bundled'])); + setupReviewSkillMocks(); + + const skills = await disabledManager.listSkills({ force: true }); + + expect(skills.map((skill) => [skill.name, skill.level])).toEqual([ + ['review', 'project'], + ]); + expect(await disabledManager.loadSkill('simplify')).toBeNull(); + expect( + vi + .mocked(fs.readdir) + .mock.calls.some(([dirPath]) => + String(dirPath).endsWith(bundledDirSegment), + ), + ).toBe(false); + }); + + it('should keep discovery working when config lacks getDisabledSkillLevels', async () => { + const partialConfig = { + isSafeMode: () => false, + getProjectRoot: () => '/test/project', + getBareMode: () => false, + } as Config; + const partialManager = new SkillManager(partialConfig); + mockReaddirForLevels(new Set(['bundled'])); + setupReviewSkillMocks(); + + const skills = await partialManager.listSkills({ force: true }); + + expect( + skills + .filter((skill) => skill.level === 'bundled') + .map((skill) => skill.name), + ).toEqual(['review', 'simplify']); + }); + it('should prioritize project-level over bundled skills with same name', async () => { mockReaddirForLevels(new Set(['project', 'bundled'])); setupReviewSkillMocks(); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 1e32bfac834..5506616cafd 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -960,6 +960,11 @@ export class SkillManager { return []; } + if (this.config.getDisabledSkillLevels?.().has(level)) { + debugLogger.debug(`Skipping disabled ${level} skill level`); + return []; + } + const projectRoot = this.config.getProjectRoot(); const homeDir = os.homedir(); const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 15adeefb61c..9f87f983927 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -935,6 +935,19 @@ "description": "Configuration for skills (SKILL.md-based capabilities) exposed to the model.", "type": "object", "properties": { + "disabledLevels": { + "description": "Skill discovery levels to skip entirely. Supported levels are project, user, extension, and bundled. UNION-merged across settings scopes.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "project", + "user", + "extension", + "bundled" + ] + } + }, "disabled": { "description": "Skill names to hide. Matched case-insensitively against the skill name. Hidden skills do not appear in or as / slash commands. UNION-merged across systemDefaults/user/workspace/system scopes — workspace cannot remove entries defined in higher scopes.", "type": "array",