Merge pull request #2202 from QwenLM/feature/support-agents-directory-skills

feat: support skills in .agents directory and other provider directories
This commit is contained in:
顾盼 2026-03-19 10:36:43 +08:00 committed by GitHub
commit fda4e85503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 197 additions and 80 deletions

View file

@ -12,6 +12,7 @@ import { getProjectHash, sanitizeCwd } from '../utils/paths.js';
export const QWEN_DIR = '.qwen'; export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const OAUTH_FILE = 'oauth_creds.json'; export const OAUTH_FILE = 'oauth_creds.json';
export const SKILL_PROVIDER_CONFIG_DIRS = ['.qwen', '.agent'];
const TMP_DIR_NAME = 'tmp'; const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin'; const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects'; const PROJECT_DIR_NAME = 'projects';
@ -138,8 +139,11 @@ export class Storage {
return path.join(this.getExtensionsDir(), 'qwen-extension.json'); return path.join(this.getExtensionsDir(), 'qwen-extension.json');
} }
getUserSkillsDir(): string { getUserSkillsDirs(): string[] {
return path.join(Storage.getGlobalQwenDir(), 'skills'); const homeDir = os.homedir() || os.tmpdir();
return SKILL_PROVIDER_CONFIG_DIRS.map((dir) =>
path.join(homeDir, dir, 'skills'),
);
} }
getHistoryFilePath(): string { getHistoryFilePath(): string {

View file

@ -57,14 +57,10 @@ function getWindowsPathFingerprint(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
pathKeys: string[], pathKeys: string[],
): string { ): string {
return pathKeys return pathKeys.map((key) => `${key}=${env[key] ?? ''}`).join('\0');
.map((key) => `${key}=${env[key] ?? ''}`)
.join('\0');
} }
function normalizePathEnvForWindows( function normalizePathEnvForWindows(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
env: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
if (os.platform() !== 'win32') { if (os.platform() !== 'win32') {
return env; return env;
} }

View file

@ -73,6 +73,14 @@ describe('SkillManager', () => {
if (yamlString.includes('name: regular-skill')) { if (yamlString.includes('name: regular-skill')) {
return { name: 'regular-skill', description: 'A regular skill' }; return { name: 'regular-skill', description: 'A regular skill' };
} }
if (yamlString.includes('name: shared-skill')) {
const desc = yamlString.includes('From qwen dir')
? 'From qwen dir'
: yamlString.includes('From agent dir')
? 'From agent dir'
: 'A shared skill';
return { name: 'shared-skill', description: desc };
}
if (!yamlString.includes('name:')) { if (!yamlString.includes('name:')) {
return { description: 'A test skill' }; // Missing name case return { description: 'A test skill' }; // Missing name case
} }
@ -391,42 +399,61 @@ You are a helpful assistant.
describe('listSkills', () => { describe('listSkills', () => {
beforeEach(() => { beforeEach(() => {
// Mock directory listing for skills directories (with Dirent objects) // Mock directory listing based on path to handle multiple base dirs per level.
vi.mocked(fs.readdir) // Use path.join to construct expected paths so separators match on all platforms.
.mockResolvedValueOnce([ const projectQwenSkillsDir = path.join(
{ '/test/project',
name: 'skill1', '.qwen',
isDirectory: () => true, 'skills',
isFile: () => false, );
isSymbolicLink: () => false, const userQwenSkillsDir = path.join('/home/user', '.qwen', 'skills');
},
{ // eslint-disable-next-line @typescript-eslint/no-explicit-any
name: 'skill2', vi.mocked(fs.readdir).mockImplementation((dirPath: any) => {
isDirectory: () => true, const pathStr = String(dirPath);
isFile: () => false, if (pathStr === projectQwenSkillsDir) {
isSymbolicLink: () => false, return Promise.resolve([
}, {
{ name: 'skill1',
name: 'not-a-dir.txt', isDirectory: () => true,
isDirectory: () => false, isFile: () => false,
isFile: () => true, isSymbolicLink: () => false,
isSymbolicLink: () => false, },
}, {
] as unknown as Awaited<ReturnType<typeof fs.readdir>>) name: 'skill2',
.mockResolvedValueOnce([ isDirectory: () => true,
{ isFile: () => false,
name: 'skill3', isSymbolicLink: () => false,
isDirectory: () => true, },
isFile: () => false, {
isSymbolicLink: () => false, name: 'not-a-dir.txt',
}, isDirectory: () => false,
{ isFile: () => true,
name: 'skill1', isSymbolicLink: () => false,
isDirectory: () => true, },
isFile: () => false, ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
isSymbolicLink: () => false, }
}, if (pathStr === userQwenSkillsDir) {
] as unknown as Awaited<ReturnType<typeof fs.readdir>>); return Promise.resolve([
{
name: 'skill3',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
{
name: 'skill1',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
}
// Other provider dirs (.agent, .cursor, .codex, .claude) return empty
return Promise.resolve(
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
);
});
vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.access).mockResolvedValue(undefined);
@ -483,6 +510,66 @@ Skill 3 content`);
expect(projectSkills.every((s) => s.level === 'project')).toBe(true); expect(projectSkills.every((s) => s.level === 'project')).toBe(true);
}); });
it('should deduplicate same-name skills across provider dirs within a level', async () => {
// Override readdir to return the same skill name from both .qwen and .agent dirs
vi.mocked(fs.readdir).mockReset();
const projectQwenDir = path.join('/test/project', '.qwen', 'skills');
const projectAgentDir = path.join('/test/project', '.agent', 'skills');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockImplementation((dirPath: any) => {
const pathStr = String(dirPath);
if (pathStr === projectQwenDir) {
return Promise.resolve([
{
name: 'shared-skill',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
}
if (pathStr === projectAgentDir) {
return Promise.resolve([
{
name: 'shared-skill',
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
},
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
}
return Promise.resolve(
[] as unknown as Awaited<ReturnType<typeof fs.readdir>>,
);
});
vi.mocked(fs.readFile).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.qwen') && pathStr.includes('shared-skill')) {
return Promise.resolve(
`---\nname: shared-skill\ndescription: From qwen dir\n---\nQwen content`,
);
}
if (pathStr.includes('.agent') && pathStr.includes('shared-skill')) {
return Promise.resolve(
`---\nname: shared-skill\ndescription: From agent dir\n---\nAgent content`,
);
}
return Promise.reject(new Error('File not found'));
});
const skills = await manager.listSkills({
level: 'project',
force: true,
});
// Only one instance should remain, from .qwen (first in PROVIDER_CONFIG_DIRS)
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('shared-skill');
expect(skills[0].description).toBe('From qwen dir');
});
it('should handle empty directories', async () => { it('should handle empty directories', async () => {
vi.mocked(fs.readdir).mockReset(); vi.mocked(fs.readdir).mockReset();
vi.mocked(fs.readdir).mockResolvedValue( vi.mocked(fs.readdir).mockResolvedValue(
@ -504,27 +591,33 @@ Skill 3 content`);
}); });
}); });
describe('getSkillsBaseDir', () => { describe('getSkillsBaseDirs', () => {
it('should return project-level base dir', () => { it('should return all project-level base dirs', () => {
const baseDir = manager.getSkillsBaseDir('project'); const baseDirs = manager.getSkillsBaseDirs('project');
expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills')); expect(baseDirs).toHaveLength(2);
expect(baseDirs).toContain(path.join('/test/project', '.qwen', 'skills'));
expect(baseDirs).toContain(
path.join('/test/project', '.agent', 'skills'),
);
}); });
it('should return user-level base dir', () => { it('should return all user-level base dirs', () => {
const baseDir = manager.getSkillsBaseDir('user'); const baseDirs = manager.getSkillsBaseDirs('user');
expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills')); expect(baseDirs).toHaveLength(2);
expect(baseDirs).toContain(path.join('/home/user', '.qwen', 'skills'));
expect(baseDirs).toContain(path.join('/home/user', '.agent', 'skills'));
}); });
it('should return bundled-level base dir', () => { it('should return bundled-level base dir', () => {
const baseDir = manager.getSkillsBaseDir('bundled'); const baseDirs = manager.getSkillsBaseDirs('bundled');
expect(baseDir).toMatch(/skills[/\\]bundled$/); expect(baseDirs[0]).toMatch(/skills[/\\]bundled$/);
}); });
it('should throw for extension level', () => { it('should throw for extension level', () => {
expect(() => manager.getSkillsBaseDir('extension')).toThrow( expect(() => manager.getSkillsBaseDirs('extension')).toThrow(
'Extension skills do not have a base directory', 'Extension skills do not have a base directory',
); );
}); });

View file

@ -22,6 +22,7 @@ import type { Config } from '../config/config.js';
import { validateConfig } from './skill-load.js'; import { validateConfig } from './skill-load.js';
import { createDebugLogger } from '../utils/debugLogger.js'; import { createDebugLogger } from '../utils/debugLogger.js';
import { normalizeContent } from '../utils/textUtils.js'; import { normalizeContent } from '../utils/textUtils.js';
import { SKILL_PROVIDER_CONFIG_DIRS } from '../config/storage.js';
const debugLogger = createDebugLogger('SKILL_MANAGER'); const debugLogger = createDebugLogger('SKILL_MANAGER');
@ -428,20 +429,20 @@ export class SkillManager {
* Gets the base directory for skills at a specific level. * Gets the base directory for skills at a specific level.
* *
* @param level - Storage level * @param level - Storage level
* @returns Absolute directory path * @returns Absolute directory paths
*/ */
getSkillsBaseDir(level: SkillLevel): string { getSkillsBaseDirs(level: SkillLevel): string[] {
switch (level) { switch (level) {
case 'project': case 'project':
return path.join( return SKILL_PROVIDER_CONFIG_DIRS.map((v) =>
this.config.getProjectRoot(), path.join(this.config.getProjectRoot(), v, SKILLS_CONFIG_DIR),
QWEN_CONFIG_DIR,
SKILLS_CONFIG_DIR,
); );
case 'user': case 'user':
return path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); return SKILL_PROVIDER_CONFIG_DIRS.map((v) =>
path.join(os.homedir(), v, SKILLS_CONFIG_DIR),
);
case 'bundled': case 'bundled':
return this.bundledSkillsDir; return [this.bundledSkillsDir];
case 'extension': case 'extension':
throw new Error( throw new Error(
'Extension skills do not have a base directory; they are loaded from active extensions.', 'Extension skills do not have a base directory; they are loaded from active extensions.',
@ -499,9 +500,26 @@ export class SkillManager {
return skills; return skills;
} }
const baseDir = this.getSkillsBaseDir(level); // Iterate provider directories in PROVIDER_CONFIG_DIRS order.
debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`); // The first directory that contains a skill with a given name wins,
const skills = await this.loadSkillsFromDir(baseDir, level); // so the order defines implicit precedence (.qwen > .agent > .cursor > ...).
const baseDirs = this.getSkillsBaseDirs(level);
const skills: SkillConfig[] = [];
const seenNames = new Set<string>();
for (const baseDir of baseDirs) {
debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`);
const skillsFromDir = await this.loadSkillsFromDir(baseDir, level);
for (const skill of skillsFromDir) {
if (seenNames.has(skill.name)) {
debugLogger.debug(
`Skipping duplicate skill at ${level} level: ${skill.name} from ${baseDir}`,
);
continue;
}
seenNames.add(skill.name);
skills.push(skill);
}
}
debugLogger.debug(`Loaded ${skills.length} ${level} level skills`); debugLogger.debug(`Loaded ${skills.length} ${level} level skills`);
return skills; return skills;
} }
@ -624,7 +642,8 @@ export class SkillManager {
private updateWatchersFromCache(): void { private updateWatchersFromCache(): void {
const watchTargets = new Set<string>( const watchTargets = new Set<string>(
(['project', 'user'] as const) (['project', 'user'] as const)
.map((level) => this.getSkillsBaseDir(level)) .map((level) => this.getSkillsBaseDirs(level))
.reduce((acc, baseDirs) => acc.concat(baseDirs), [])
.filter((baseDir) => fsSync.existsSync(baseDir)), .filter((baseDir) => fsSync.existsSync(baseDir)),
); );
@ -680,7 +699,7 @@ export class SkillManager {
} }
private async ensureUserSkillsDir(): Promise<void> { private async ensureUserSkillsDir(): Promise<void> {
const baseDir = this.getSkillsBaseDir('user'); const baseDir = path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR);
try { try {
await fs.mkdir(baseDir, { recursive: true }); await fs.mkdir(baseDir, { recursive: true });
} catch (error) { } catch (error) {

View file

@ -43,7 +43,7 @@ describe('LSTool', () => {
}), }),
getTruncateToolOutputLines: () => 1000, getTruncateToolOutputLines: () => 1000,
storage: { storage: {
getUserSkillsDir: () => userSkillsBase, getUserSkillsDirs: () => [userSkillsBase],
}, },
} as unknown as Config; } as unknown as Config;

View file

@ -9,7 +9,7 @@ import path from 'node:path';
import type { ToolInvocation, ToolResult } from './tools.js'; import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
import { isSubpath } from '../utils/paths.js'; import { isSubpaths } from '../utils/paths.js';
import type { Config } from '../config/config.js'; import type { Config } from '../config/config.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
@ -335,8 +335,8 @@ export class LSTool extends BaseDeclarativeTool<LSToolParams, ToolResult> {
return `Path must be absolute: ${params.path}`; return `Path must be absolute: ${params.path}`;
} }
const userSkillsBase = this.config.storage.getUserSkillsDir(); const userSkillsBases = this.config.storage.getUserSkillsDirs();
const isUnderUserSkills = isSubpath(userSkillsBase, params.path); const isUnderUserSkills = isSubpaths(userSkillsBases, params.path);
const workspaceContext = this.config.getWorkspaceContext(); const workspaceContext = this.config.getWorkspaceContext();
if ( if (

View file

@ -40,7 +40,7 @@ describe('ReadFileTool', () => {
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
storage: { storage: {
getProjectTempDir: () => path.join(tempRootDir, '.temp'), getProjectTempDir: () => path.join(tempRootDir, '.temp'),
getUserSkillsDir: () => path.join(os.homedir(), '.qwen', 'skills'), getUserSkillsDirs: () => [path.join(os.homedir(), '.qwen', 'skills')],
}, },
getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputThreshold: () => 2500,
getTruncateToolOutputLines: () => 500, getTruncateToolOutputLines: () => 500,

View file

@ -21,7 +21,7 @@ import { FileOperation } from '../telemetry/metrics.js';
import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js';
import { logFileOperation } from '../telemetry/loggers.js'; import { logFileOperation } from '../telemetry/loggers.js';
import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperationEvent } from '../telemetry/types.js';
import { isSubpath } from '../utils/paths.js'; import { isSubpaths, isSubpath } from '../utils/paths.js';
import { Storage } from '../config/storage.js'; import { Storage } from '../config/storage.js';
/** /**
@ -187,7 +187,7 @@ export class ReadFileTool extends BaseDeclarativeTool<
const workspaceContext = this.config.getWorkspaceContext(); const workspaceContext = this.config.getWorkspaceContext();
const globalTempDir = Storage.getGlobalTempDir(); const globalTempDir = Storage.getGlobalTempDir();
const projectTempDir = this.config.storage.getProjectTempDir(); const projectTempDir = this.config.storage.getProjectTempDir();
const userSkillsDir = this.config.storage.getUserSkillsDir(); const userSkillsDirs = this.config.storage.getUserSkillsDirs();
const arenaDir = Storage.getGlobalArenaDir(); const arenaDir = Storage.getGlobalArenaDir();
const resolvedFilePath = path.resolve(filePath); const resolvedFilePath = path.resolve(filePath);
const osTempDir = os.tmpdir(); const osTempDir = os.tmpdir();
@ -195,8 +195,9 @@ export class ReadFileTool extends BaseDeclarativeTool<
isSubpath(projectTempDir, resolvedFilePath) || isSubpath(projectTempDir, resolvedFilePath) ||
isSubpath(globalTempDir, resolvedFilePath) || isSubpath(globalTempDir, resolvedFilePath) ||
isSubpath(osTempDir, resolvedFilePath); isSubpath(osTempDir, resolvedFilePath);
const isWithinUserSkills = isSubpaths(userSkillsDirs, resolvedFilePath);
const isWithinArenaDir = isSubpath(arenaDir, resolvedFilePath); const isWithinArenaDir = isSubpath(arenaDir, resolvedFilePath);
const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath);
if ( if (
!workspaceContext.isPathWithinWorkspace(filePath) && !workspaceContext.isPathWithinWorkspace(filePath) &&

View file

@ -57,7 +57,7 @@ describe('ShellTool', () => {
.fn() .fn()
.mockReturnValue(createMockWorkspaceContext('/test/dir')), .mockReturnValue(createMockWorkspaceContext('/test/dir')),
storage: { storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'), getUserSkillsDirs: vi.fn().mockReturnValue(['/test/dir/.qwen/skills']),
getProjectTempDir: vi.fn().mockReturnValue('/tmp/qwen-temp'), getProjectTempDir: vi.fn().mockReturnValue('/tmp/qwen-temp'),
}, },
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(0), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(0),

View file

@ -34,7 +34,7 @@ import type {
import { ShellExecutionService } from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js';
import { isSubpath } from '../utils/paths.js'; import { isSubpaths } from '../utils/paths.js';
import { import {
getCommandRoots, getCommandRoots,
isCommandAllowed, isCommandAllowed,
@ -622,10 +622,10 @@ export class ShellTool extends BaseDeclarativeTool<
return 'Directory must be an absolute path.'; return 'Directory must be an absolute path.';
} }
const userSkillsDir = this.config.storage.getUserSkillsDir(); const userSkillsDirs = this.config.storage.getUserSkillsDirs();
const resolvedDirectoryPath = path.resolve(params.directory); const resolvedDirectoryPath = path.resolve(params.directory);
const isWithinUserSkills = isSubpath( const isWithinUserSkills = isSubpaths(
userSkillsDir, userSkillsDirs,
resolvedDirectoryPath, resolvedDirectoryPath,
); );
if (isWithinUserSkills) { if (isWithinUserSkills) {

View file

@ -241,6 +241,10 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
); );
} }
export function isSubpaths(parentPath: string[], childPath: string): boolean {
return parentPath.some((p) => isSubpath(p, childPath));
}
/** /**
* Resolves a path with tilde (~) expansion and relative path resolution. * Resolves a path with tilde (~) expansion and relative path resolution.
* Handles tilde expansion for home directory and resolves relative paths * Handles tilde expansion for home directory and resolves relative paths