mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
fix(core): support .toml command files in extension command discovery (#5017)
* fix(core): support .toml command files in extension command discovery
loadCommandsFromDir only globbed for **/*.md, causing extensions like
caveman that ship .toml commands to have their commands silently ignored
during installation and loading. The CLI-layer FileCommandLoader already
supports both formats, but the core discovery function did not.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(extension): address CR comments on .toml command discovery
- Merge two separate glob calls into single **/*.{md,toml} pattern
- Fix Windows path separator regression: use /[/\\]/ instead of path.sep
- Remove Set dedup in loadCommandsFromDir so consent UI shows true count
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(extension): add colon sanitization test for command names
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test(extension): add ENOENT branch coverage for missing commands directory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4363d58758
commit
bc2a5cfbb7
2 changed files with 175 additions and 8 deletions
|
|
@ -331,6 +331,176 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('ext2');
|
||||
});
|
||||
|
||||
describe('command discovery', () => {
|
||||
it('should discover .md command files', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'md-commands-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'greet.md'), 'Hello!');
|
||||
fs.writeFileSync(path.join(commandsDir, 'farewell.md'), 'Bye!');
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find((e) => e.config.name === 'md-commands-ext');
|
||||
expect(ext?.commands).toEqual(
|
||||
expect.arrayContaining(['greet', 'farewell']),
|
||||
);
|
||||
expect(ext?.commands).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should discover .toml command files', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'toml-commands-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(commandsDir, 'caveman.toml'),
|
||||
'prompt = "Talk like caveman"\ndescription = "Caveman mode"',
|
||||
);
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find(
|
||||
(e) => e.config.name === 'toml-commands-ext',
|
||||
);
|
||||
expect(ext?.commands).toEqual(['caveman']);
|
||||
});
|
||||
|
||||
it('should discover both .md and .toml command files', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'mixed-commands-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'greet.md'), 'Hello!');
|
||||
fs.writeFileSync(
|
||||
path.join(commandsDir, 'caveman.toml'),
|
||||
'prompt = "Talk like caveman"',
|
||||
);
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find(
|
||||
(e) => e.config.name === 'mixed-commands-ext',
|
||||
);
|
||||
expect(ext?.commands).toEqual(
|
||||
expect.arrayContaining(['greet', 'caveman']),
|
||||
);
|
||||
expect(ext?.commands).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should list both entries when .md and .toml exist for same command name', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'dedup-commands-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'greet.md'), 'Hello!');
|
||||
fs.writeFileSync(
|
||||
path.join(commandsDir, 'greet.toml'),
|
||||
'prompt = "Hello!"',
|
||||
);
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find(
|
||||
(e) => e.config.name === 'dedup-commands-ext',
|
||||
);
|
||||
// No dedup at discovery level — both entries surface so the consent
|
||||
// UI shows the true count; downstream CommandService handles conflicts.
|
||||
expect(ext?.commands).toEqual(['greet', 'greet']);
|
||||
});
|
||||
|
||||
it('should discover nested .toml command files with colon-separated names', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'nested-toml-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const nestedDir = path.join(extDir, 'commands', 'caveman');
|
||||
fs.mkdirSync(nestedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(nestedDir, 'intensity.toml'),
|
||||
'prompt = "Switch intensity"',
|
||||
);
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find((e) => e.config.name === 'nested-toml-ext');
|
||||
expect(ext?.commands).toEqual(['caveman:intensity']);
|
||||
});
|
||||
|
||||
it('should replace colons in path segments with underscores', async () => {
|
||||
if (process.platform !== 'linux') return; // colons forbidden in filenames on macOS/Windows
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'colon-name-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'foo:bar.md'), 'content');
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
const ext = extensions.find((e) => e.config.name === 'colon-name-ext');
|
||||
expect(ext?.commands).toEqual(['foo_bar']);
|
||||
});
|
||||
|
||||
it('should return empty commands when commands directory does not exist', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'no-cmd-dir-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
const ext = extensions.find((e) => e.config.name === 'no-cmd-dir-ext');
|
||||
expect(ext?.commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty commands when no .md or .toml files exist', async () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'no-commands-ext',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const commandsDir = path.join(extDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'readme.txt'), 'not a cmd');
|
||||
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
const extensions = manager.getLoadedExtensions();
|
||||
|
||||
const ext = extensions.find((e) => e.config.name === 'no-commands-ext');
|
||||
expect(ext?.commands).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableExtension / disableExtension', () => {
|
||||
|
|
|
|||
|
|
@ -240,19 +240,16 @@ async function loadCommandsFromDir(dir: string): Promise<string[]> {
|
|||
};
|
||||
|
||||
try {
|
||||
const mdFiles = await glob('**/*.md', {
|
||||
const allFiles = await glob('**/*.{md,toml}', {
|
||||
...globOptions,
|
||||
cwd: dir,
|
||||
});
|
||||
|
||||
const commandNames = mdFiles.map((file) => {
|
||||
const relativePathWithExt = path.relative(dir, path.join(dir, file));
|
||||
const relativePath = relativePathWithExt.substring(
|
||||
0,
|
||||
relativePathWithExt.length - 3,
|
||||
);
|
||||
const commandNames = allFiles.map((file) => {
|
||||
const ext = path.extname(file);
|
||||
const relativePath = file.substring(0, file.length - ext.length);
|
||||
const commandName = relativePath
|
||||
.split(path.sep)
|
||||
.split(/[/\\]/)
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue