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:
callmeYe 2026-06-12 17:54:28 +08:00 committed by GitHub
parent 4363d58758
commit bc2a5cfbb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 175 additions and 8 deletions

View file

@ -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', () => {

View file

@ -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(':');