Skip to content

Commit 011f3d2

Browse files
authored
Merge pull request #1580 from QwenLM/feat/extension-improvements
feat(extensions): add detail command and improve extension validation
2 parents 2aa681f + 674bb63 commit 011f3d2

File tree

8 files changed

+300
-13
lines changed

8 files changed

+300
-13
lines changed

packages/cli/src/commands/extensions/utils.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
*/
66

77
import { describe, it, expect, vi, beforeEach } from 'vitest';
8-
import { getExtensionManager } from './utils.js';
8+
import { getExtensionManager, extensionToOutputString } from './utils.js';
9+
import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core';
910

1011
const mockRefreshCache = vi.fn();
1112
const mockExtensionManagerInstance = {
@@ -64,3 +65,70 @@ describe('getExtensionManager', () => {
6465
);
6566
});
6667
});
68+
69+
describe('extensionToOutputString', () => {
70+
const mockIsEnabled = vi.fn();
71+
const mockExtensionManager = {
72+
isEnabled: mockIsEnabled,
73+
} as unknown as ExtensionManager;
74+
75+
const createMockExtension = (overrides = {}): Extension => ({
76+
id: 'test-ext-id',
77+
name: 'test-extension',
78+
version: '1.0.0',
79+
isActive: true,
80+
path: '/path/to/extension',
81+
contextFiles: [],
82+
config: { name: 'test-extension', version: '1.0.0' },
83+
...overrides,
84+
});
85+
86+
beforeEach(() => {
87+
vi.clearAllMocks();
88+
mockIsEnabled.mockReturnValue(true);
89+
});
90+
91+
it('should include status icon when inline is false', () => {
92+
const extension = createMockExtension();
93+
const result = extensionToOutputString(
94+
extension,
95+
mockExtensionManager,
96+
'/workspace',
97+
false,
98+
);
99+
100+
// Should contain either ✓ or ✗ (with ANSI color codes)
101+
expect(result).toMatch(/test-extension/);
102+
expect(result).toContain('(1.0.0)');
103+
});
104+
105+
it('should exclude status icon when inline is true', () => {
106+
const extension = createMockExtension();
107+
const result = extensionToOutputString(
108+
extension,
109+
mockExtensionManager,
110+
'/workspace',
111+
true,
112+
);
113+
114+
// Should start with extension name (after stripping potential whitespace)
115+
expect(result.trim()).toMatch(/^test-extension/);
116+
});
117+
118+
it('should default inline to false', () => {
119+
const extension = createMockExtension();
120+
const resultWithoutInline = extensionToOutputString(
121+
extension,
122+
mockExtensionManager,
123+
'/workspace',
124+
);
125+
const resultWithInlineFalse = extensionToOutputString(
126+
extension,
127+
mockExtensionManager,
128+
'/workspace',
129+
false,
130+
);
131+
132+
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
133+
});
134+
});

packages/cli/src/commands/extensions/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function extensionToOutputString(
3232
extension: Extension,
3333
extensionManager: ExtensionManager,
3434
workspaceDir: string,
35+
inline = false,
3536
): string {
3637
const cwd = workspaceDir;
3738
const userEnabled = extensionManager.isEnabled(
@@ -44,7 +45,7 @@ export function extensionToOutputString(
4445
);
4546

4647
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
47-
let output = `${status} ${extension.config.name} (${extension.config.version})`;
48+
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
4849
output += `\n Path: ${extension.path}`;
4950
if (extension.installMetadata) {
5051
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;

packages/cli/src/ui/commands/extensionsCommand.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,4 +777,87 @@ describe('extensionsCommand', () => {
777777
);
778778
});
779779
});
780+
781+
describe('detail', () => {
782+
const detailAction = extensionsCommand.subCommands?.find(
783+
(cmd) => cmd.name === 'detail',
784+
)?.action;
785+
786+
if (!detailAction) {
787+
throw new Error('Detail action not found');
788+
}
789+
790+
let realMockExtensionManager: ExtensionManager;
791+
792+
beforeEach(() => {
793+
vi.resetAllMocks();
794+
realMockExtensionManager = Object.create(ExtensionManager.prototype);
795+
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
796+
797+
mockContext = createMockCommandContext({
798+
invocation: {
799+
raw: '/extensions detail',
800+
name: 'detail',
801+
args: '',
802+
},
803+
services: {
804+
config: {
805+
getExtensions: mockGetExtensions,
806+
getWorkingDir: () => '/test/dir',
807+
getExtensionManager: () => realMockExtensionManager,
808+
},
809+
},
810+
ui: {
811+
dispatchExtensionStateUpdate: vi.fn(),
812+
},
813+
});
814+
});
815+
816+
it('should show usage if no name is provided', async () => {
817+
await detailAction(mockContext, '');
818+
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
819+
{
820+
type: MessageType.ERROR,
821+
text: 'Usage: /extensions detail <extension-name>',
822+
},
823+
expect.any(Number),
824+
);
825+
});
826+
827+
it('should show error if extension not found', async () => {
828+
mockGetExtensions.mockReturnValue([]);
829+
await detailAction(mockContext, 'nonexistent-extension');
830+
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
831+
{
832+
type: MessageType.ERROR,
833+
text: 'Extension "nonexistent-extension" not found.',
834+
},
835+
expect.any(Number),
836+
);
837+
});
838+
839+
it('should show extension details when found', async () => {
840+
const extension: Extension = {
841+
id: 'test-ext',
842+
name: 'test-ext',
843+
version: '1.0.0',
844+
isActive: true,
845+
path: '/test/dir/test-ext',
846+
contextFiles: [],
847+
config: { name: 'test-ext', version: '1.0.0' },
848+
};
849+
mockGetExtensions.mockReturnValue([extension]);
850+
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
851+
852+
await detailAction(mockContext, 'test-ext');
853+
854+
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
855+
{
856+
type: MessageType.INFO,
857+
text: expect.stringContaining('test-ext'),
858+
},
859+
expect.any(Number),
860+
);
861+
});
862+
});
780863
});

packages/cli/src/ui/commands/extensionsCommand.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '@qwen-code/qwen-code-core';
2121
import { SettingScope } from '../../config/settings.js';
2222
import open from 'open';
23+
import { extensionToOutputString } from '../../commands/extensions/utils.js';
2324

2425
const EXTENSION_EXPLORE_URL = {
2526
Gemini: 'https://geminicli.com/extensions/',
@@ -475,6 +476,53 @@ async function enableAction(context: CommandContext, args: string) {
475476
}
476477
}
477478

479+
async function detailAction(context: CommandContext, args: string) {
480+
const extensionManager = context.services.config?.getExtensionManager();
481+
if (!(extensionManager instanceof ExtensionManager)) {
482+
console.error(
483+
`Cannot ${context.invocation?.name} extensions in this environment`,
484+
);
485+
return;
486+
}
487+
488+
const name = args.trim();
489+
if (!name) {
490+
context.ui.addItem(
491+
{
492+
type: MessageType.ERROR,
493+
text: t('Usage: /extensions detail <extension-name>'),
494+
},
495+
Date.now(),
496+
);
497+
return;
498+
}
499+
500+
const extensions = context.services.config!.getExtensions();
501+
const extension = extensions.find((extension) => extension.name === name);
502+
if (!extension) {
503+
context.ui.addItem(
504+
{
505+
type: MessageType.ERROR,
506+
text: t('Extension "{{name}}" not found.', { name }),
507+
},
508+
Date.now(),
509+
);
510+
return;
511+
}
512+
context.ui.addItem(
513+
{
514+
type: MessageType.INFO,
515+
text: extensionToOutputString(
516+
extension,
517+
extensionManager,
518+
process.cwd(),
519+
true,
520+
),
521+
},
522+
Date.now(),
523+
);
524+
}
525+
478526
export async function completeExtensions(
479527
context: CommandContext,
480528
partialArg: string,
@@ -495,7 +543,10 @@ export async function completeExtensions(
495543
name.startsWith(partialArg),
496544
);
497545

498-
if (context.invocation?.name !== 'uninstall') {
546+
if (
547+
context.invocation?.name !== 'uninstall' &&
548+
context.invocation?.name !== 'detail'
549+
) {
499550
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
500551
suggestions.unshift('--all');
501552
}
@@ -594,6 +645,16 @@ const uninstallCommand: SlashCommand = {
594645
completion: completeExtensions,
595646
};
596647

648+
const detailCommand: SlashCommand = {
649+
name: 'detail',
650+
get description() {
651+
return t('Get detail of an extension');
652+
},
653+
kind: CommandKind.BUILT_IN,
654+
action: detailAction,
655+
completion: completeExtensions,
656+
};
657+
597658
export const extensionsCommand: SlashCommand = {
598659
name: 'extensions',
599660
get description() {
@@ -608,6 +669,7 @@ export const extensionsCommand: SlashCommand = {
608669
installCommand,
609670
uninstallCommand,
610671
exploreExtensionsCommand,
672+
detailCommand,
611673
],
612674
action: (context, args) =>
613675
// Default to list if no subcommand is provided

packages/core/src/extension/extensionManager.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,30 @@ describe('extension tests', () => {
218218
]);
219219
});
220220

221+
it('should use default QWEN.md when contextFileName is empty array', async () => {
222+
const extDir = path.join(userExtensionsDir, 'ext-empty-context');
223+
fs.mkdirSync(extDir, { recursive: true });
224+
fs.writeFileSync(
225+
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
226+
JSON.stringify({
227+
name: 'ext-empty-context',
228+
version: '1.0.0',
229+
contextFileName: [],
230+
}),
231+
);
232+
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content');
233+
234+
const manager = createExtensionManager();
235+
await manager.refreshCache();
236+
const extensions = manager.getLoadedExtensions();
237+
238+
expect(extensions).toHaveLength(1);
239+
const ext = extensions.find((e) => e.config.name === 'ext-empty-context');
240+
expect(ext?.contextFiles).toEqual([
241+
path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'),
242+
]);
243+
});
244+
221245
it('should skip extensions with invalid JSON and log a warning', async () => {
222246
const consoleSpy = vi
223247
.spyOn(console, 'error')
@@ -694,13 +718,14 @@ describe('extension tests', () => {
694718
expect(() => validateName('UPPERCASE')).not.toThrow();
695719
});
696720

721+
it('should accept names with underscores and dots', () => {
722+
expect(() => validateName('my_extension')).not.toThrow();
723+
expect(() => validateName('my.extension')).not.toThrow();
724+
expect(() => validateName('my_ext.v1')).not.toThrow();
725+
expect(() => validateName('ext_1.2.3')).not.toThrow();
726+
});
727+
697728
it('should reject names with invalid characters', () => {
698-
expect(() => validateName('my_extension')).toThrow(
699-
'Invalid extension name',
700-
);
701-
expect(() => validateName('my.extension')).toThrow(
702-
'Invalid extension name',
703-
);
704729
expect(() => validateName('my extension')).toThrow(
705730
'Invalid extension name',
706731
);

packages/core/src/extension/extensionManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
190190
}
191191

192192
function getContextFileNames(config: ExtensionConfig): string[] {
193-
if (!config.contextFileName) {
193+
if (!config.contextFileName || config.contextFileName.length === 0) {
194194
return ['QWEN.md'];
195195
} else if (!Array.isArray(config.contextFileName)) {
196196
return [config.contextFileName];
@@ -1244,9 +1244,9 @@ export function hashValue(value: string): string {
12441244
}
12451245

12461246
export function validateName(name: string) {
1247-
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
1247+
if (!/^[a-zA-Z0-9-_.]+$/.test(name)) {
12481248
throw new Error(
1249-
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
1249+
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), underscores (_), dots (.), and dashes (-) are allowed.`,
12501250
);
12511251
}
12521252
}

0 commit comments

Comments
 (0)