basant307/AI_Governance_Project
045
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as path from 'node:path';8import type { Config } from '@qwen-code/qwen-code-core';9import { Storage } from '@qwen-code/qwen-code-core';10import mock from 'mock-fs';11import { FileCommandLoader } from './FileCommandLoader.js';12import { assert, vi } from 'vitest';13import { createMockCommandContext } from '../test-utils/mockCommandContext.js';14import {15 SHELL_INJECTION_TRIGGER,16 SHORTHAND_ARGS_PLACEHOLDER,17 type PromptPipelineContent,18} from './prompt-processors/types.js';19import {20 ConfirmationRequiredError,21 ShellProcessor,22} from './prompt-processors/shellProcessor.js';23import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';24import type { CommandContext } from '../ui/commands/types.js';25import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';26 27const mockShellProcess = vi.hoisted(() => vi.fn());28const mockAtFileProcess = vi.hoisted(() => vi.fn());29vi.mock('./prompt-processors/atFileProcessor.js', () => ({30 AtFileProcessor: vi.fn().mockImplementation(() => ({31 process: mockAtFileProcess,32 })),33}));34vi.mock('./prompt-processors/shellProcessor.js', () => ({35 ShellProcessor: vi.fn().mockImplementation(() => ({36 process: mockShellProcess,37 })),38 ConfirmationRequiredError: class extends Error {39 constructor(40 message: string,41 public commandsToConfirm: string[],42 ) {43 super(message);44 this.name = 'ConfirmationRequiredError';45 }46 },47}));48 49vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => {50 const original =51 await importOriginal<52 typeof import('./prompt-processors/argumentProcessor.js')53 >();54 return {55 DefaultArgumentProcessor: vi56 .fn()57 .mockImplementation(() => new original.DefaultArgumentProcessor()),58 };59});60vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {61 const original =62 await importOriginal<typeof import('@qwen-code/qwen-code-core')>();63 return {64 ...original,65 Storage: original.Storage,66 isCommandAllowed: vi.fn(),67 ShellExecutionService: {68 execute: vi.fn(),69 },70 };71});72 73describe('FileCommandLoader', () => {74 const signal: AbortSignal = new AbortController().signal;75 76 beforeEach(() => {77 vi.clearAllMocks();78 mockShellProcess.mockImplementation(79 (prompt: PromptPipelineContent, context: CommandContext) => {80 const userArgsRaw = context?.invocation?.args || '';81 // This is a simplified mock. A real implementation would need to iterate82 // through all parts and process only the text parts.83 const firstTextPart = prompt.find(84 (p) => typeof p === 'string' || 'text' in p,85 );86 let textContent = '';87 if (typeof firstTextPart === 'string') {88 textContent = firstTextPart;89 } else if (firstTextPart && 'text' in firstTextPart) {90 textContent = firstTextPart.text ?? '';91 }92 93 const processedText = textContent.replaceAll(94 SHORTHAND_ARGS_PLACEHOLDER,95 userArgsRaw,96 );97 return Promise.resolve([{ text: processedText }]);98 },99 );100 mockAtFileProcess.mockImplementation(async (prompt: string) => prompt);101 });102 103 afterEach(() => {104 mock.restore();105 });106 107 it('loads a single command from a file', async () => {108 const userCommandsDir = Storage.getUserCommandsDir();109 mock({110 [userCommandsDir]: {111 'test.toml': 'prompt = "This is a test prompt"',112 },113 });114 115 const loader = new FileCommandLoader(null);116 const commands = await loader.loadCommands(signal);117 118 expect(commands).toHaveLength(1);119 const command = commands[0];120 expect(command).toBeDefined();121 expect(command.name).toBe('test');122 expect(command.sourceLabel).toBe('Custom');123 expect(command.sourceDetail).toBe('custom');124 125 const result = await command.action?.(126 createMockCommandContext({127 invocation: {128 raw: '/test',129 name: 'test',130 args: '',131 },132 }),133 '',134 );135 if (result?.type === 'submit_prompt') {136 expect(result.content).toEqual([{ text: 'This is a test prompt' }]);137 } else {138 assert.fail('Incorrect action type');139 }140 });141 142 // Symlink creation on Windows requires special permissions that are not143 // available in the standard CI environment. Therefore, we skip these tests144 // on Windows to prevent CI failures. The core functionality is still145 // validated on Linux and macOS.146 const itif = (condition: boolean) => (condition ? it : it.skip);147 148 itif(process.platform !== 'win32')(149 'loads commands from a symlinked directory',150 async () => {151 const userCommandsDir = Storage.getUserCommandsDir();152 const realCommandsDir = '/real/commands';153 mock({154 [realCommandsDir]: {155 'test.toml': 'prompt = "This is a test prompt"',156 },157 // Symlink the user commands directory to the real one158 [userCommandsDir]: mock.symlink({159 path: realCommandsDir,160 }),161 });162 163 const loader = new FileCommandLoader(null as unknown as Config);164 const commands = await loader.loadCommands(signal);165 166 expect(commands).toHaveLength(1);167 const command = commands[0];168 expect(command).toBeDefined();169 expect(command.name).toBe('test');170 },171 );172 173 itif(process.platform !== 'win32')(174 'loads commands from a symlinked subdirectory',175 async () => {176 const userCommandsDir = Storage.getUserCommandsDir();177 const realNamespacedDir = '/real/namespaced-commands';178 mock({179 [userCommandsDir]: {180 namespaced: mock.symlink({181 path: realNamespacedDir,182 }),183 },184 [realNamespacedDir]: {185 'my-test.toml': 'prompt = "This is a test prompt"',186 },187 });188 189 const loader = new FileCommandLoader(null as unknown as Config);190 const commands = await loader.loadCommands(signal);191 192 expect(commands).toHaveLength(1);193 const command = commands[0];194 expect(command).toBeDefined();195 expect(command.name).toBe('namespaced:my-test');196 },197 );198 199 it('loads multiple commands', async () => {200 const userCommandsDir = Storage.getUserCommandsDir();201 mock({202 [userCommandsDir]: {203 'test1.toml': 'prompt = "Prompt 1"',204 'test2.toml': 'prompt = "Prompt 2"',205 },206 });207 208 const loader = new FileCommandLoader(null);209 const commands = await loader.loadCommands(signal);210 211 expect(commands).toHaveLength(2);212 });213 214 it('creates deeply nested namespaces correctly', async () => {215 const userCommandsDir = Storage.getUserCommandsDir();216 217 mock({218 [userCommandsDir]: {219 gcp: {220 pipelines: {221 'run.toml': 'prompt = "run pipeline"',222 },223 },224 },225 });226 const mockConfig = {227 getProjectRoot: vi.fn(() => '/path/to/project'),228 getExtensions: vi.fn(() => []),229 getFolderTrustFeature: vi.fn(() => false),230 getFolderTrust: vi.fn(() => false),231 } as unknown as Config;232 const loader = new FileCommandLoader(mockConfig);233 const commands = await loader.loadCommands(signal);234 expect(commands).toHaveLength(1);235 expect(commands[0]!.name).toBe('gcp:pipelines:run');236 });237 238 it('creates namespaces from nested directories', async () => {239 const userCommandsDir = Storage.getUserCommandsDir();240 mock({241 [userCommandsDir]: {242 git: {243 'commit.toml': 'prompt = "git commit prompt"',244 },245 },246 });247 248 const loader = new FileCommandLoader(null);249 const commands = await loader.loadCommands(signal);250 251 expect(commands).toHaveLength(1);252 const command = commands[0];253 expect(command).toBeDefined();254 expect(command.name).toBe('git:commit');255 });256 257 it('returns both user and project commands in order', async () => {258 const userCommandsDir = Storage.getUserCommandsDir();259 const projectCommandsDir = new Storage(260 process.cwd(),261 ).getProjectCommandsDir();262 mock({263 [userCommandsDir]: {264 'test.toml': 'prompt = "User prompt"',265 },266 [projectCommandsDir]: {267 'test.toml': 'prompt = "Project prompt"',268 },269 });270 271 const mockConfig = {272 getProjectRoot: vi.fn(() => process.cwd()),273 getExtensions: vi.fn(() => []),274 getFolderTrustFeature: vi.fn(() => false),275 getFolderTrust: vi.fn(() => false),276 } as unknown as Config;277 const loader = new FileCommandLoader(mockConfig);278 const commands = await loader.loadCommands(signal);279 280 expect(commands).toHaveLength(2);281 const userResult = await commands[0].action?.(282 createMockCommandContext({283 invocation: {284 raw: '/test',285 name: 'test',286 args: '',287 },288 }),289 '',290 );291 if (userResult?.type === 'submit_prompt') {292 expect(userResult.content).toEqual([{ text: 'User prompt' }]);293 } else {294 assert.fail('Incorrect action type for user command');295 }296 const projectResult = await commands[1].action?.(297 createMockCommandContext({298 invocation: {299 raw: '/test',300 name: 'test',301 args: '',302 },303 }),304 '',305 );306 if (projectResult?.type === 'submit_prompt') {307 expect(projectResult.content).toEqual([{ text: 'Project prompt' }]);308 } else {309 assert.fail('Incorrect action type for project command');310 }311 });312 313 it('skips auto-discovered commands in bare mode', async () => {314 const userCommandsDir = Storage.getUserCommandsDir();315 const projectCommandsDir = new Storage(316 process.cwd(),317 ).getProjectCommandsDir();318 mock({319 [userCommandsDir]: {320 'user.toml': 'prompt = "User prompt"',321 },322 [projectCommandsDir]: {323 'project.toml': 'prompt = "Project prompt"',324 },325 });326 327 const mockConfig = {328 getProjectRoot: vi.fn(() => process.cwd()),329 getExtensions: vi.fn(() => []),330 getFolderTrustFeature: vi.fn(() => false),331 getFolderTrust: vi.fn(() => false),332 getBareMode: vi.fn(() => true),333 } as unknown as Config;334 const loader = new FileCommandLoader(mockConfig);335 const commands = await loader.loadCommands(signal);336 337 expect(commands).toEqual([]);338 });339 340 it('ignores files with TOML syntax errors', async () => {341 const userCommandsDir = Storage.getUserCommandsDir();342 mock({343 [userCommandsDir]: {344 'invalid.toml': 'this is not valid toml',345 'good.toml': 'prompt = "This one is fine"',346 },347 });348 349 const loader = new FileCommandLoader(null);350 const commands = await loader.loadCommands(signal);351 352 expect(commands).toHaveLength(1);353 expect(commands[0].name).toBe('good');354 });355 356 it('ignores files that are semantically invalid (missing prompt)', async () => {357 const userCommandsDir = Storage.getUserCommandsDir();358 mock({359 [userCommandsDir]: {360 'no_prompt.toml': 'description = "This file is missing a prompt"',361 'good.toml': 'prompt = "This one is fine"',362 },363 });364 365 const loader = new FileCommandLoader(null);366 const commands = await loader.loadCommands(signal);367 368 expect(commands).toHaveLength(1);369 expect(commands[0].name).toBe('good');370 });371 372 it('handles filename edge cases correctly', async () => {373 const userCommandsDir = Storage.getUserCommandsDir();374 mock({375 [userCommandsDir]: {376 'test.v1.toml': 'prompt = "Test prompt"',377 },378 });379 380 const loader = new FileCommandLoader(null);381 const commands = await loader.loadCommands(signal);382 const command = commands[0];383 expect(command).toBeDefined();384 expect(command.name).toBe('test.v1');385 });386 387 it('handles file system errors gracefully', async () => {388 mock({}); // Mock an empty file system389 const loader = new FileCommandLoader(null);390 const commands = await loader.loadCommands(signal);391 expect(commands).toHaveLength(0);392 });393 394 it('uses a default description if not provided', async () => {395 const userCommandsDir = Storage.getUserCommandsDir();396 mock({397 [userCommandsDir]: {398 'test.toml': 'prompt = "Test prompt"',399 },400 });401 402 const loader = new FileCommandLoader(null);403 const commands = await loader.loadCommands(signal);404 const command = commands[0];405 expect(command).toBeDefined();406 expect(command.description).toBe('Custom command from test.toml');407 });408 409 it('uses the provided description', async () => {410 const userCommandsDir = Storage.getUserCommandsDir();411 mock({412 [userCommandsDir]: {413 'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',414 },415 });416 417 const loader = new FileCommandLoader(null);418 const commands = await loader.loadCommands(signal);419 const command = commands[0];420 expect(command).toBeDefined();421 expect(command.description).toBe('My test command');422 });423 424 it('should sanitize colons in filenames to prevent namespace conflicts', async () => {425 const userCommandsDir = Storage.getUserCommandsDir();426 mock({427 [userCommandsDir]: {428 'legacy:command.toml': 'prompt = "This is a legacy command"',429 },430 });431 432 const loader = new FileCommandLoader(null);433 const commands = await loader.loadCommands(signal);434 435 expect(commands).toHaveLength(1);436 const command = commands[0];437 expect(command).toBeDefined();438 439 // Verify that the ':' in the filename was replaced with an '_'440 expect(command.name).toBe('legacy_command');441 });442 443 describe('Processor Instantiation Logic', () => {444 it('instantiates only DefaultArgumentProcessor if no {{args}} or !{} are present', async () => {445 const userCommandsDir = Storage.getUserCommandsDir();446 mock({447 [userCommandsDir]: {448 'simple.toml': `prompt = "Just a regular prompt"`,449 },450 });451 452 const loader = new FileCommandLoader(null as unknown as Config);453 await loader.loadCommands(signal);454 455 expect(ShellProcessor).not.toHaveBeenCalled();456 expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);457 });458 459 it('instantiates only ShellProcessor if {{args}} is present (but not !{})', async () => {460 const userCommandsDir = Storage.getUserCommandsDir();461 mock({462 [userCommandsDir]: {463 'args.toml': `prompt = "Prompt with {{args}}"`,464 },465 });466 467 const loader = new FileCommandLoader(null as unknown as Config);468 await loader.loadCommands(signal);469 470 expect(ShellProcessor).toHaveBeenCalledTimes(1);471 expect(DefaultArgumentProcessor).not.toHaveBeenCalled();472 });473 474 it('instantiates ShellProcessor and DefaultArgumentProcessor if !{} is present (but not {{args}})', async () => {475 const userCommandsDir = Storage.getUserCommandsDir();476 mock({477 [userCommandsDir]: {478 'shell.toml': `prompt = "Prompt with !{cmd}"`,479 },480 });481 482 const loader = new FileCommandLoader(null as unknown as Config);483 await loader.loadCommands(signal);484 485 expect(ShellProcessor).toHaveBeenCalledTimes(1);486 expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);487 });488 489 it('instantiates only ShellProcessor if both {{args}} and !{} are present', async () => {490 const userCommandsDir = Storage.getUserCommandsDir();491 mock({492 [userCommandsDir]: {493 'both.toml': `prompt = "Prompt with {{args}} and !{cmd}"`,494 },495 });496 497 const loader = new FileCommandLoader(null as unknown as Config);498 await loader.loadCommands(signal);499 500 expect(ShellProcessor).toHaveBeenCalledTimes(1);501 expect(DefaultArgumentProcessor).not.toHaveBeenCalled();502 });503 504 it('instantiates AtFileProcessor and DefaultArgumentProcessor if @{} is present', async () => {505 const userCommandsDir = Storage.getUserCommandsDir();506 mock({507 [userCommandsDir]: {508 'at-file.toml': `prompt = "Context: @{./my-file.txt}"`,509 },510 });511 512 const loader = new FileCommandLoader(null as unknown as Config);513 await loader.loadCommands(signal);514 515 expect(AtFileProcessor).toHaveBeenCalledTimes(1);516 expect(ShellProcessor).not.toHaveBeenCalled();517 expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1);518 });519 520 it('instantiates ShellProcessor and AtFileProcessor if !{} and @{} are present', async () => {521 const userCommandsDir = Storage.getUserCommandsDir();522 mock({523 [userCommandsDir]: {524 'shell-and-at.toml': `prompt = "Run !{cmd} with @{file.txt}"`,525 },526 });527 528 const loader = new FileCommandLoader(null as unknown as Config);529 await loader.loadCommands(signal);530 531 expect(ShellProcessor).toHaveBeenCalledTimes(1);532 expect(AtFileProcessor).toHaveBeenCalledTimes(1);533 expect(DefaultArgumentProcessor).toHaveBeenCalledTimes(1); // because no {{args}}534 });535 536 it('instantiates only ShellProcessor and AtFileProcessor if {{args}} and @{} are present', async () => {537 const userCommandsDir = Storage.getUserCommandsDir();538 mock({539 [userCommandsDir]: {540 'args-and-at.toml': `prompt = "Run {{args}} with @{file.txt}"`,541 },542 });543 544 const loader = new FileCommandLoader(null as unknown as Config);545 await loader.loadCommands(signal);546 547 expect(ShellProcessor).toHaveBeenCalledTimes(1);548 expect(AtFileProcessor).toHaveBeenCalledTimes(1);549 expect(DefaultArgumentProcessor).not.toHaveBeenCalled();550 });551 });552 553 describe('Extension Command Loading', () => {554 it('loads commands from active extensions', async () => {555 const userCommandsDir = Storage.getUserCommandsDir();556 const projectCommandsDir = new Storage(557 process.cwd(),558 ).getProjectCommandsDir();559 const extensionDir = path.join(560 process.cwd(),561 '.qwen/extensions/test-ext',562 );563 564 mock({565 [userCommandsDir]: {566 'user.toml': 'prompt = "User command"',567 },568 [projectCommandsDir]: {569 'project.toml': 'prompt = "Project command"',570 },571 [extensionDir]: {572 'qwen-extension.json': JSON.stringify({573 name: 'test-ext',574 version: '1.0.0',575 }),576 commands: {577 'ext.toml': 'prompt = "Extension command"',578 },579 },580 });581 582 const mockConfig = {583 getProjectRoot: vi.fn(() => process.cwd()),584 getExtensions: vi.fn(() => [585 {586 name: 'test-ext',587 version: '1.0.0',588 isActive: true,589 path: extensionDir,590 },591 ]),592 getFolderTrustFeature: vi.fn(() => false),593 getFolderTrust: vi.fn(() => false),594 } as unknown as Config;595 const loader = new FileCommandLoader(mockConfig);596 const commands = await loader.loadCommands(signal);597 598 expect(commands).toHaveLength(3);599 const commandNames = commands.map((cmd) => cmd.name);600 expect(commandNames).toEqual(['user', 'project', 'ext']);601 602 const extCommand = commands.find((cmd) => cmd.name === 'ext');603 expect(extCommand?.extensionName).toBe('test-ext');604 expect(extCommand?.description).toMatch(/^\[test-ext\]/);605 });606 607 it('extension commands have extensionName metadata for conflict resolution', async () => {608 const userCommandsDir = Storage.getUserCommandsDir();609 const projectCommandsDir = new Storage(610 process.cwd(),611 ).getProjectCommandsDir();612 const extensionDir = path.join(613 process.cwd(),614 '.qwen/extensions/test-ext',615 );616 617 mock({618 [extensionDir]: {619 'qwen-extension.json': JSON.stringify({620 name: 'test-ext',621 version: '1.0.0',622 }),623 commands: {624 'deploy.toml': 'prompt = "Extension deploy command"',625 },626 },627 [userCommandsDir]: {628 'deploy.toml': 'prompt = "User deploy command"',629 },630 [projectCommandsDir]: {631 'deploy.toml': 'prompt = "Project deploy command"',632 },633 });634 635 const mockConfig = {636 getProjectRoot: vi.fn(() => process.cwd()),637 getExtensions: vi.fn(() => [638 {639 name: 'test-ext',640 version: '1.0.0',641 isActive: true,642 path: extensionDir,643 },644 ]),645 getFolderTrustFeature: vi.fn(() => false),646 getFolderTrust: vi.fn(() => false),647 } as unknown as Config;648 const loader = new FileCommandLoader(mockConfig);649 const commands = await loader.loadCommands(signal);650 651 // Return all commands, even duplicates652 expect(commands).toHaveLength(3);653 654 expect(commands[0].name).toBe('deploy');655 expect(commands[0].extensionName).toBeUndefined();656 const result0 = await commands[0].action?.(657 createMockCommandContext({658 invocation: {659 raw: '/deploy',660 name: 'deploy',661 args: '',662 },663 }),664 '',665 );666 expect(result0?.type).toBe('submit_prompt');667 if (result0?.type === 'submit_prompt') {668 expect(result0.content).toEqual([{ text: 'User deploy command' }]);669 }670 671 expect(commands[1].name).toBe('deploy');672 expect(commands[1].extensionName).toBeUndefined();673 const result1 = await commands[1].action?.(674 createMockCommandContext({675 invocation: {676 raw: '/deploy',677 name: 'deploy',678 args: '',679 },680 }),681 '',682 );683 expect(result1?.type).toBe('submit_prompt');684 if (result1?.type === 'submit_prompt') {685 expect(result1.content).toEqual([{ text: 'Project deploy command' }]);686 }687 688 expect(commands[2].name).toBe('deploy');689 expect(commands[2].extensionName).toBe('test-ext');690 expect(commands[2].description).toMatch(/^\[test-ext\]/);691 const result2 = await commands[2].action?.(692 createMockCommandContext({693 invocation: {694 raw: '/test-ext.deploy',695 name: 'test-ext.deploy',696 args: '',697 },698 }),699 '',700 );701 expect(result2?.type).toBe('submit_prompt');702 if (result2?.type === 'submit_prompt') {703 expect(result2.content).toEqual([{ text: 'Extension deploy command' }]);704 }705 });706 707 it('only loads commands from active extensions', async () => {708 const extensionDir1 = path.join(709 process.cwd(),710 '.qwen/extensions/active-ext',711 );712 const extensionDir2 = path.join(713 process.cwd(),714 '.qwen/extensions/inactive-ext',715 );716 717 mock({718 [extensionDir1]: {719 'qwen-extension.json': JSON.stringify({720 name: 'active-ext',721 version: '1.0.0',722 }),723 commands: {724 'active.toml': 'prompt = "Active extension command"',725 },726 },727 [extensionDir2]: {728 'qwen-extension.json': JSON.stringify({729 name: 'inactive-ext',730 version: '1.0.0',731 }),732 commands: {733 'inactive.toml': 'prompt = "Inactive extension command"',734 },735 },736 });737 738 const mockConfig = {739 getProjectRoot: vi.fn(() => process.cwd()),740 getExtensions: vi.fn(() => [741 {742 name: 'active-ext',743 version: '1.0.0',744 isActive: true,745 path: extensionDir1,746 },747 {748 name: 'inactive-ext',749 version: '1.0.0',750 isActive: false,751 path: extensionDir2,752 },753 ]),754 getFolderTrustFeature: vi.fn(() => false),755 getFolderTrust: vi.fn(() => false),756 } as unknown as Config;757 const loader = new FileCommandLoader(mockConfig);758 const commands = await loader.loadCommands(signal);759 760 expect(commands).toHaveLength(1);761 expect(commands[0].name).toBe('active');762 expect(commands[0].extensionName).toBe('active-ext');763 expect(commands[0].description).toMatch(/^\[active-ext\]/);764 });765 766 it('handles missing extension commands directory gracefully', async () => {767 const extensionDir = path.join(768 process.cwd(),769 '.qwen/extensions/no-commands',770 );771 772 mock({773 [extensionDir]: {774 'qwen-extension.json': JSON.stringify({775 name: 'no-commands',776 version: '1.0.0',777 }),778 // No commands directory779 },780 });781 782 const mockConfig = {783 getProjectRoot: vi.fn(() => process.cwd()),784 getExtensions: vi.fn(() => [785 {786 name: 'no-commands',787 version: '1.0.0',788 isActive: true,789 path: extensionDir,790 },791 ]),792 getFolderTrustFeature: vi.fn(() => false),793 getFolderTrust: vi.fn(() => false),794 } as unknown as Config;795 const loader = new FileCommandLoader(mockConfig);796 const commands = await loader.loadCommands(signal);797 expect(commands).toHaveLength(0);798 });799 800 it('handles nested command structure in extensions', async () => {801 const extensionDir = path.join(process.cwd(), '.qwen/extensions/a');802 803 mock({804 [extensionDir]: {805 'qwen-extension.json': JSON.stringify({806 name: 'a',807 version: '1.0.0',808 }),809 commands: {810 b: {811 'c.toml': 'prompt = "Nested command from extension a"',812 d: {813 'e.toml': 'prompt = "Deeply nested command"',814 },815 },816 'simple.toml': 'prompt = "Simple command"',817 },818 },819 });820 821 const mockConfig = {822 getProjectRoot: vi.fn(() => process.cwd()),823 getExtensions: vi.fn(() => [824 { name: 'a', version: '1.0.0', isActive: true, path: extensionDir },825 ]),826 getFolderTrustFeature: vi.fn(() => false),827 getFolderTrust: vi.fn(() => false),828 } as unknown as Config;829 const loader = new FileCommandLoader(mockConfig);830 const commands = await loader.loadCommands(signal);831 832 expect(commands).toHaveLength(3);833 834 const commandNames = commands.map((cmd) => cmd.name).sort();835 expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']);836 837 const nestedCmd = commands.find((cmd) => cmd.name === 'b:c');838 expect(nestedCmd?.extensionName).toBe('a');839 expect(nestedCmd?.description).toMatch(/^\[a\]/);840 expect(nestedCmd).toBeDefined();841 const result = await nestedCmd!.action?.(842 createMockCommandContext({843 invocation: {844 raw: '/a.b:c',845 name: 'a.b:c',846 args: '',847 },848 }),849 '',850 );851 if (result?.type === 'submit_prompt') {852 expect(result.content).toEqual([853 { text: 'Nested command from extension a' },854 ]);855 } else {856 assert.fail('Incorrect action type');857 }858 });859 });860 861 describe('Argument Handling Integration (via ShellProcessor)', () => {862 it('correctly processes a command with {{args}}', async () => {863 const userCommandsDir = Storage.getUserCommandsDir();864 mock({865 [userCommandsDir]: {866 'shorthand.toml':867 'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"',868 },869 });870 871 const loader = new FileCommandLoader(null as unknown as Config);872 const commands = await loader.loadCommands(signal);873 const command = commands.find((c) => c.name === 'shorthand');874 expect(command).toBeDefined();875 876 const result = await command!.action?.(877 createMockCommandContext({878 invocation: {879 raw: '/shorthand do something cool',880 name: 'shorthand',881 args: 'do something cool',882 },883 }),884 'do something cool',885 );886 expect(result?.type).toBe('submit_prompt');887 if (result?.type === 'submit_prompt') {888 expect(result.content).toEqual([889 { text: 'The user wants to: do something cool' },890 ]);891 }892 });893 });894 895 describe('Default Argument Processor Integration', () => {896 it('correctly processes a command without {{args}}', async () => {897 const userCommandsDir = Storage.getUserCommandsDir();898 mock({899 [userCommandsDir]: {900 'model_led.toml':901 'prompt = "This is the instruction."\ndescription = "Default processor test"',902 },903 });904 905 const loader = new FileCommandLoader(null as unknown as Config);906 const commands = await loader.loadCommands(signal);907 const command = commands.find((c) => c.name === 'model_led');908 expect(command).toBeDefined();909 910 const result = await command!.action?.(911 createMockCommandContext({912 invocation: {913 raw: '/model_led 1.2.0 added "a feature"',914 name: 'model_led',915 args: '1.2.0 added "a feature"',916 },917 }),918 '1.2.0 added "a feature"',919 );920 expect(result?.type).toBe('submit_prompt');921 if (result?.type === 'submit_prompt') {922 const expectedContent =923 'This is the instruction.\n\n/model_led 1.2.0 added "a feature"';924 expect(result.content).toEqual([{ text: expectedContent }]);925 }926 });927 });928 929 describe('Shell Processor Integration', () => {930 it('instantiates ShellProcessor if {{args}} is present (even without shell trigger)', async () => {931 const userCommandsDir = Storage.getUserCommandsDir();932 mock({933 [userCommandsDir]: {934 'args_only.toml': `prompt = "Hello {{args}}"`,935 },936 });937 938 const loader = new FileCommandLoader(null as unknown as Config);939 await loader.loadCommands(signal);940 941 expect(ShellProcessor).toHaveBeenCalledWith('args_only');942 });943 it('instantiates ShellProcessor if the trigger is present', async () => {944 const userCommandsDir = Storage.getUserCommandsDir();945 mock({946 [userCommandsDir]: {947 'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`,948 },949 });950 951 const loader = new FileCommandLoader(null as unknown as Config);952 await loader.loadCommands(signal);953 954 expect(ShellProcessor).toHaveBeenCalledWith('shell');955 });956 957 it('does not instantiate ShellProcessor if no triggers ({{args}} or !{}) are present', async () => {958 const userCommandsDir = Storage.getUserCommandsDir();959 mock({960 [userCommandsDir]: {961 'regular.toml': `prompt = "Just a regular prompt"`,962 },963 });964 965 const loader = new FileCommandLoader(null as unknown as Config);966 await loader.loadCommands(signal);967 968 expect(ShellProcessor).not.toHaveBeenCalled();969 });970 971 it('returns a "submit_prompt" action if shell processing succeeds', async () => {972 const userCommandsDir = Storage.getUserCommandsDir();973 mock({974 [userCommandsDir]: {975 'shell.toml': `prompt = "Run !{echo 'hello'}"`,976 },977 });978 mockShellProcess.mockResolvedValue([{ text: 'Run hello' }]);979 980 const loader = new FileCommandLoader(null as unknown as Config);981 const commands = await loader.loadCommands(signal);982 const command = commands.find((c) => c.name === 'shell');983 expect(command).toBeDefined();984 985 const result = await command!.action!(986 createMockCommandContext({987 invocation: { raw: '/shell', name: 'shell', args: '' },988 }),989 '',990 );991 992 expect(result?.type).toBe('submit_prompt');993 if (result?.type === 'submit_prompt') {994 expect(result.content).toEqual([{ text: 'Run hello' }]);995 }996 });997 998 it('returns a "confirm_shell_commands" action if shell processing requires it', async () => {999 const userCommandsDir = Storage.getUserCommandsDir();1000 const rawInvocation = '/shell rm -rf /';1001 mock({1002 [userCommandsDir]: {1003 'shell.toml': `prompt = "Run !{rm -rf /}"`,1004 },1005 });1006 1007 // Mock the processor to throw the specific error1008 const error = new ConfirmationRequiredError('Confirmation needed', [1009 'rm -rf /',1010 ]);1011 mockShellProcess.mockRejectedValue(error);1012 1013 const loader = new FileCommandLoader(null as unknown as Config);1014 const commands = await loader.loadCommands(signal);1015 const command = commands.find((c) => c.name === 'shell');1016 expect(command).toBeDefined();1017 1018 const result = await command!.action!(1019 createMockCommandContext({1020 invocation: { raw: rawInvocation, name: 'shell', args: 'rm -rf /' },1021 }),1022 'rm -rf /',1023 );1024 1025 expect(result?.type).toBe('confirm_shell_commands');1026 if (result?.type === 'confirm_shell_commands') {1027 expect(result.commandsToConfirm).toEqual(['rm -rf /']);1028 expect(result.originalInvocation.raw).toBe(rawInvocation);1029 }1030 });1031 1032 it('re-throws other errors from the processor', async () => {1033 const userCommandsDir = Storage.getUserCommandsDir();1034 mock({1035 [userCommandsDir]: {1036 'shell.toml': `prompt = "Run !{something}"`,1037 },1038 });1039 1040 const genericError = new Error('Something else went wrong');1041 mockShellProcess.mockRejectedValue(genericError);1042 1043 const loader = new FileCommandLoader(null as unknown as Config);1044 const commands = await loader.loadCommands(signal);1045 const command = commands.find((c) => c.name === 'shell');1046 expect(command).toBeDefined();1047 1048 await expect(1049 command!.action!(1050 createMockCommandContext({1051 invocation: { raw: '/shell', name: 'shell', args: '' },1052 }),1053 '',1054 ),1055 ).rejects.toThrow('Something else went wrong');1056 });1057 it('assembles the processor pipeline in the correct order (AtFile -> Shell -> Default)', async () => {1058 const userCommandsDir = Storage.getUserCommandsDir();1059 mock({1060 [userCommandsDir]: {1061 // This prompt uses !{}, @{}, but NOT {{args}}, so all processors should be active.1062 'pipeline.toml': `1063 prompt = "Shell says: !{echo foo}. File says: @{./bar.txt}"1064 `,1065 },1066 './bar.txt': 'bar content',1067 });1068 1069 const defaultProcessMock = vi1070 .fn()1071 .mockImplementation((p: PromptPipelineContent) =>1072 Promise.resolve([1073 { text: `${(p[0] as { text: string }).text}-default-processed` },1074 ]),1075 );1076 1077 mockShellProcess.mockImplementation((p: PromptPipelineContent) =>1078 Promise.resolve([1079 { text: `${(p[0] as { text: string }).text}-shell-processed` },1080 ]),1081 );1082 1083 mockAtFileProcess.mockImplementation((p: PromptPipelineContent) =>1084 Promise.resolve([1085 { text: `${(p[0] as { text: string }).text}-at-file-processed` },1086 ]),1087 );1088 1089 vi.mocked(DefaultArgumentProcessor).mockImplementation(1090 () =>1091 ({1092 process: defaultProcessMock,1093 }) as unknown as DefaultArgumentProcessor,1094 );1095 1096 const loader = new FileCommandLoader(null as unknown as Config);1097 const commands = await loader.loadCommands(signal);1098 const command = commands.find((c) => c.name === 'pipeline');1099 expect(command).toBeDefined();1100 1101 const result = await command!.action!(1102 createMockCommandContext({1103 invocation: {1104 raw: '/pipeline baz',1105 name: 'pipeline',1106 args: 'baz',1107 },1108 }),1109 'baz',1110 );1111 1112 expect(mockAtFileProcess.mock.invocationCallOrder[0]).toBeLessThan(1113 mockShellProcess.mock.invocationCallOrder[0],1114 );1115 expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan(1116 defaultProcessMock.mock.invocationCallOrder[0],1117 );1118 1119 // Verify the flow of the prompt through the processors1120 // 1. AtFile processor runs first1121 expect(mockAtFileProcess).toHaveBeenCalledWith(1122 [{ text: expect.stringContaining('@{./bar.txt}') }],1123 expect.any(Object),1124 );1125 // 2. Shell processor runs second1126 expect(mockShellProcess).toHaveBeenCalledWith(1127 [{ text: expect.stringContaining('-at-file-processed') }],1128 expect.any(Object),1129 );1130 // 3. Default processor runs third1131 expect(defaultProcessMock).toHaveBeenCalledWith(1132 [{ text: expect.stringContaining('-shell-processed') }],1133 expect.any(Object),1134 );1135 1136 if (result?.type === 'submit_prompt') {1137 const contentAsArray = Array.isArray(result.content)1138 ? result.content1139 : [result.content];1140 expect(contentAsArray.length).toBeGreaterThan(0);1141 const firstPart = contentAsArray[0];1142 1143 if (typeof firstPart === 'object' && firstPart && 'text' in firstPart) {1144 expect(firstPart.text).toContain(1145 '-at-file-processed-shell-processed-default-processed',1146 );1147 } else {1148 assert.fail(1149 'First part of content is not a text part or is a string',1150 );1151 }1152 } else {1153 assert.fail('Incorrect action type');1154 }1155 });1156 });1157 1158 describe('@-file Processor Integration', () => {1159 it('correctly processes a command with @{file}', async () => {1160 const userCommandsDir = Storage.getUserCommandsDir();1161 mock({1162 [userCommandsDir]: {1163 'at-file.toml':1164 'prompt = "Context from file: @{./test.txt}"\ndescription = "@-file test"',1165 },1166 './test.txt': 'file content',1167 });1168 1169 mockAtFileProcess.mockImplementation(1170 async (prompt: PromptPipelineContent) => {1171 // A simplified mock of AtFileProcessor's behavior1172 const textContent = (prompt[0] as { text: string }).text;1173 if (textContent.includes('@{./test.txt}')) {1174 return [1175 {1176 text: textContent.replace('@{./test.txt}', 'file content'),1177 },1178 ];1179 }1180 return prompt;1181 },1182 );1183 1184 // Prevent default processor from interfering1185 vi.mocked(DefaultArgumentProcessor).mockImplementation(1186 () =>1187 ({1188 process: (p: PromptPipelineContent) => Promise.resolve(p),1189 }) as unknown as DefaultArgumentProcessor,1190 );1191 1192 const loader = new FileCommandLoader(null as unknown as Config);1193 const commands = await loader.loadCommands(signal);1194 const command = commands.find((c) => c.name === 'at-file');1195 expect(command).toBeDefined();1196 1197 const result = await command!.action?.(1198 createMockCommandContext({1199 invocation: {1200 raw: '/at-file',