From afbbb3e23d54fb045f1e7f3a3f5ebce30c9d8557 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 11 Feb 2026 14:27:08 -0500 Subject: [PATCH 1/4] feat(ui): add source indicators to slash commands --- .../src/services/FileCommandLoader.test.ts | 83 ++++++++++++ .../cli/src/services/FileCommandLoader.ts | 21 ++- packages/cli/src/ui/commands/types.ts | 7 + .../ui/components/SuggestionsDisplay.test.tsx | 38 +++++- .../src/ui/components/SuggestionsDisplay.tsx | 19 ++- .../SuggestionsDisplay.test.tsx.snap | 6 + .../src/ui/hooks/useSlashCompletion.test.ts | 128 ++++++++++++++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 1 + 8 files changed, 295 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 077b8c45fe7..bac47b97017 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -1402,4 +1402,87 @@ describe('FileCommandLoader', () => { expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); + + describe('CommandSource Assignment', () => { + it('assigns CommandSource.USER to user commands', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'user-cmd.toml': 'prompt = "User prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + expect(commands[0].source).toBe('user'); + }); + + it('assigns CommandSource.PROJECT to project commands', async () => { + const projectCommandsDir = new Storage( + process.cwd(), + ).getProjectCommandsDir(); + mock({ + [projectCommandsDir]: { + 'project-cmd.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + // 0 is user (empty), 1 is project + const projectCmd = commands.find((c) => c.name === 'project-cmd'); + expect(projectCmd).toBeDefined(); + expect(projectCmd?.source).toBe('project'); + }); + + it('assigns CommandSource.EXTENSION to extension commands', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'test-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'test-ext', + version: '1.0.0', + }), + commands: { + 'ext-cmd.toml': 'prompt = "Extension prompt"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const extCmd = commands.find((c) => c.name === 'ext-cmd'); + expect(extCmd).toBeDefined(); + expect(extCmd?.source).toBe('extension'); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fb27327ead2..e858fd9ec85 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -17,7 +17,7 @@ import type { SlashCommand, SlashCommandActionReturn, } from '../ui/commands/types.js'; -import { CommandKind } from '../ui/commands/types.js'; +import { CommandKind, CommandSource } from '../ui/commands/types.js'; import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; import type { IPromptProcessor, @@ -39,6 +39,7 @@ interface CommandDirectory { path: string; extensionName?: string; extensionId?: string; + source: CommandSource; } /** @@ -113,6 +114,7 @@ export class FileCommandLoader implements ICommandLoader { dirInfo.path, dirInfo.extensionName, dirInfo.extensionId, + dirInfo.source, ), ); @@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); + dirs.push({ + path: Storage.getUserCommandsDir(), + source: CommandSource.USER, + }); // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); + dirs.push({ + path: storage.getProjectCommandsDir(), + source: CommandSource.PROJECT, + }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -167,6 +175,7 @@ export class FileCommandLoader implements ICommandLoader { path: path.join(ext.path, 'commands'), extensionName: ext.name, extensionId: ext.id, + source: CommandSource.EXTENSION, })); dirs.push(...extensionCommandDirs); @@ -185,8 +194,9 @@ export class FileCommandLoader implements ICommandLoader { private async parseAndAdaptFile( filePath: string, baseDir: string, - extensionName?: string, - extensionId?: string, + extensionName: string | undefined, + extensionId: string | undefined, + source: CommandSource, ): Promise { let fileContent: string; try { @@ -289,6 +299,7 @@ export class FileCommandLoader implements ICommandLoader { kind: CommandKind.FILE, extensionName, extensionId, + source, action: async ( context: CommandContext, _args: string, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7e..6c2d65a77b1 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -182,6 +182,12 @@ export enum CommandKind { AGENT = 'agent', } +export enum CommandSource { + USER = 'user', + PROJECT = 'project', + EXTENSION = 'extension', +} + // The standardized contract for any command in the system. export interface SlashCommand { name: string; @@ -190,6 +196,7 @@ export interface SlashCommand { hidden?: boolean; kind: CommandKind; + source?: CommandSource; /** * Controls whether the command auto-executes when selected with Enter. diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx index 6931268a379..e87470bf6a9 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx @@ -7,7 +7,7 @@ import { render } from '../../test-utils/render.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { describe, it, expect } from 'vitest'; -import { CommandKind } from '../commands/types.js'; +import { CommandKind, CommandSource } from '../commands/types.js'; describe('SuggestionsDisplay', () => { const mockSuggestions = [ @@ -121,4 +121,40 @@ describe('SuggestionsDisplay', () => { ); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders source tags for file commands', () => { + const sourcedSuggestions = [ + { + label: 'User Cmd', + value: 'user-cmd', + commandKind: CommandKind.FILE, + source: CommandSource.USER, + }, + { + label: 'Project Cmd', + value: 'project-cmd', + commandKind: CommandKind.FILE, + source: CommandSource.PROJECT, + }, + { + label: 'Ext Cmd', + value: 'ext-cmd', + commandKind: CommandKind.FILE, + source: CommandSource.EXTENSION, + }, + ]; + + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); }); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index d9498e7a6bb..9eae409ec3f 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -7,7 +7,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js'; -import { CommandKind } from '../commands/types.js'; +import { CommandKind, CommandSource } from '../commands/types.js'; import { Colors } from '../colors.js'; import { sanitizeForDisplay } from '../utils/textUtils.js'; @@ -17,6 +17,7 @@ export interface Suggestion { description?: string; matchedIndex?: number; commandKind?: CommandKind; + source?: CommandSource; } interface SuggestionsDisplayProps { suggestions: Suggestion[]; @@ -67,8 +68,16 @@ export function SuggestionsDisplay({ [CommandKind.AGENT]: ' [Agent]', }; + const COMMAND_SOURCE_SUFFIX: Partial> = { + [CommandSource.USER]: ' [USER]', + [CommandSource.PROJECT]: ' [PROJECT]', + [CommandSource.EXTENSION]: ' [EXT]', + }; + const getFullLabel = (s: Suggestion) => - s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : ''); + s.label + + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '') + + (s.source ? (COMMAND_SOURCE_SUFFIX[s.source] ?? '') : ''); const maxLabelLength = Math.max( ...suggestions.map((s) => getFullLabel(s).length), @@ -111,6 +120,12 @@ export function SuggestionsDisplay({ {COMMAND_KIND_SUFFIX[suggestion.commandKind]} )} + {suggestion.source && + COMMAND_SOURCE_SUFFIX[suggestion.source] && ( + + {COMMAND_SOURCE_SUFFIX[suggestion.source]} + + )} diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap index ce1640ce257..5e625a1da03 100644 --- a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap @@ -24,6 +24,12 @@ exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `" mcp-tool exports[`SuggestionsDisplay > renders loading state 1`] = `" Loading suggestions..."`; +exports[`SuggestionsDisplay > renders source tags for file commands 1`] = ` +" user-cmd [USER] + project-cmd [PROJECT] + ext-cmd [EXT]" +`; + exports[`SuggestionsDisplay > renders suggestions list 1`] = ` " command1 Description 1 command2 Description 2 diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index ea320b80a13..79abd8e866f 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1108,6 +1108,134 @@ describe('useSlashCompletion', () => { }); }); + describe('Command Source Information', () => { + it('should include source for file commands in suggestions', async () => { + const slashCommands = [ + { + name: 'user-cmd', + description: 'User command', + kind: CommandKind.FILE, + source: 'user', + action: vi.fn(), + }, + { + name: 'project-cmd', + description: 'Project command', + kind: CommandKind.FILE, + source: 'project', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + + const { result, unmount } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'user-cmd', + value: 'user-cmd', + description: 'User command', + commandKind: CommandKind.FILE, + source: 'user', + }, + { + label: 'project-cmd', + value: 'project-cmd', + description: 'Project command', + commandKind: CommandKind.FILE, + source: 'project', + }, + ]), + ); + }); + unmount(); + }); + + it('should include source when filtering commands by prefix', async () => { + const slashCommands = [ + { + name: 'deploy', + description: 'Deploy project', + kind: CommandKind.FILE, + source: 'project', + action: vi.fn(), + }, + ] as unknown as SlashCommand[]; + + const { result, unmount } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/dep', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'deploy', + value: 'deploy', + description: 'Deploy project', + commandKind: CommandKind.FILE, + source: 'project', + }, + ]); + }); + unmount(); + }); + + it('should include source for sub-commands', async () => { + const slashCommands = [ + { + name: 'ext', + description: 'Extension parent', + kind: CommandKind.FILE, + source: 'extension', + subCommands: [ + { + name: 'cmd', + description: 'Extension command', + kind: CommandKind.FILE, + source: 'extension', + action: vi.fn(), + }, + ], + }, + ] as unknown as SlashCommand[]; + + const { result, unmount } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/ext ', + slashCommands, + mockCommandContext, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions).toEqual([ + { + label: 'cmd', + value: 'cmd', + description: 'Extension command', + commandKind: CommandKind.FILE, + source: 'extension', + }, + ]); + }); + unmount(); + }); + }); + it('should not call shared callbacks when disabled', async () => { const mockSetSuggestions = vi.fn(); const mockSetIsLoadingSuggestions = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7809d6cf0fe..c0bbf0f055e 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -316,6 +316,7 @@ function useCommandSuggestions( value: cmd.name, description: cmd.description, commandKind: cmd.kind, + source: cmd.source, })); setSuggestions(finalSuggestions); From b37528ee5ef72ec799b85615424043b7bd058958 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Thu, 12 Feb 2026 14:23:32 -0500 Subject: [PATCH 2/4] Replace static [USER], [PROJECT], [EXT] labels with the actual name. --- .../src/services/FileCommandLoader.test.ts | 155 ++++++++++++++---- .../cli/src/services/FileCommandLoader.ts | 40 +++-- packages/cli/src/ui/commands/types.ts | 7 - .../ui/components/SuggestionsDisplay.test.tsx | 38 +---- .../src/ui/components/SuggestionsDisplay.tsx | 19 +-- .../SuggestionsDisplay.test.tsx.snap | 6 - .../src/ui/hooks/useSlashCompletion.test.ts | 128 --------------- .../cli/src/ui/hooks/useSlashCompletion.ts | 1 - 8 files changed, 154 insertions(+), 240 deletions(-) diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index bac47b97017..dba469efb05 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -6,6 +6,7 @@ import * as glob from 'glob'; import * as path from 'node:path'; +import * as os from 'node:os'; import type { Config } from '@google/gemini-cli-core'; import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; import mock from 'mock-fs'; @@ -75,6 +76,14 @@ vi.mock('glob', () => ({ glob: vi.fn(), })); +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + userInfo: vi.fn().mockReturnValue({ username: 'mock-user' }), + }; +}); + describe('FileCommandLoader', () => { const signal: AbortSignal = new AbortController().signal; @@ -382,7 +391,9 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); - expect(command.description).toBe('Custom command from test.toml'); + expect(command.description).toBe( + '[mock-user] Custom command from test.toml', + ); }); it('uses the provided description', async () => { @@ -397,7 +408,7 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); - expect(command.description).toBe('My test command'); + expect(command.description).toBe('[mock-user] My test command'); }); it('should sanitize colons in filenames to prevent namespace conflicts', async () => { @@ -1382,8 +1393,10 @@ describe('FileCommandLoader', () => { const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); - // Newlines and tabs become spaces, ANSI is stripped - expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text'); + // Newlines and tabs become spaces, ANSI is stripped, prepended with [mock-user] + expect(commands[0].description).toBe( + '[mock-user] Line 1 Line 2 Tabbed Red text', + ); }); it('truncates long descriptions', async () => { @@ -1399,67 +1412,153 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].description.length).toBe(100); - expect(commands[0].description).toBe('d'.repeat(97) + '...'); + expect(commands[0].description).toBe( + '[mock-user] ' + 'd'.repeat(85) + '...', + ); }); }); - describe('CommandSource Assignment', () => { - it('assigns CommandSource.USER to user commands', async () => { + describe('sourceLabel Assignment', () => { + it('uses the OS username for user commands', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { - 'user-cmd.toml': 'prompt = "User prompt"', + 'test.toml': 'prompt = "User prompt"', }, }); const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); - expect(commands).toHaveLength(1); - expect(commands[0].source).toBe('user'); + expect(commands[0].description).toBe( + '[mock-user] Custom command from test.toml', + ); }); - it('assigns CommandSource.PROJECT to project commands', async () => { - const projectCommandsDir = new Storage( - process.cwd(), - ).getProjectCommandsDir(); + it('falls back to environment variables or "User" if os.userInfo() fails', async () => { + const userInfoSpy = vi.spyOn(os, 'userInfo').mockImplementation(() => { + throw new Error('userInfo failed'); + }); + + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "Fallback prompt"', + }, + }); + + // Falls back to 'USER' environment variable + vi.stubEnv('USER', 'env-user'); + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + expect(commands[0].description).toBe( + '[env-user] Custom command from test.toml', + ); + + // Falls back to 'USERNAME' environment variable + vi.stubEnv('USER', ''); + vi.stubEnv('USERNAME', 'win-user'); + const loader2 = new FileCommandLoader(null); + const commands2 = await loader2.loadCommands(signal); + expect(commands2[0].description).toBe( + '[win-user] Custom command from test.toml', + ); + + // Falls back to 'User' string + vi.stubEnv('USERNAME', ''); + const loader3 = new FileCommandLoader(null); + const commands3 = await loader3.loadCommands(signal); + expect(commands3[0].description).toBe( + '[User] Custom command from test.toml', + ); + + userInfoSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + it('uses the project root basename for project commands', async () => { + const projectRoot = '/path/to/my-awesome-project'; + const projectCommandsDir = path.join(projectRoot, GEMINI_DIR, 'commands'); + mock({ [projectCommandsDir]: { - 'project-cmd.toml': 'prompt = "Project prompt"', + 'project.toml': 'prompt = "Project prompt"', }, }); const mockConfig = { - getProjectRoot: vi.fn(() => process.cwd()), + getProjectRoot: vi.fn(() => projectRoot), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + storage: new Storage(projectRoot), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const projectCmd = commands.find((c) => c.name === 'project'); + expect(projectCmd?.description).toBe( + '[my-awesome-project] Custom command from project.toml', + ); + }); + + it('correctly handles Windows-style project root paths', async () => { + // Simulate a Windows path regardless of the current OS + const windowsProjectRoot = 'C:\\Users\\test\\projects\\win-project'; + // We use path.win32 to explicitly test Windows behavior + const expectedLabel = path.win32.basename(windowsProjectRoot); + expect(expectedLabel).toBe('win-project'); + + const projectCommandsDir = path.join( + windowsProjectRoot, + GEMINI_DIR, + 'commands', + ); + + mock({ + [projectCommandsDir]: { + 'win-cmd.toml': 'prompt = "Win prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => windowsProjectRoot), getExtensions: vi.fn(() => []), getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), + storage: new Storage(windowsProjectRoot), } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); - // 0 is user (empty), 1 is project - const projectCmd = commands.find((c) => c.name === 'project-cmd'); - expect(projectCmd).toBeDefined(); - expect(projectCmd?.source).toBe('project'); + const winCmd = commands.find((c) => c.name === 'win-cmd'); + // On non-Windows systems, path.basename might not handle \ correctly + // but FileCommandLoader uses the platform-native 'path' module. + // In the test we just want to ensure it uses WHATEVER path.basename returns. + const actualLabel = path.basename(windowsProjectRoot); + expect(winCmd?.description).toBe( + `[${actualLabel}] Custom command from win-cmd.toml`, + ); }); - it('assigns CommandSource.EXTENSION to extension commands', async () => { + it('uses the extension name for extension commands', async () => { const extensionDir = path.join( process.cwd(), GEMINI_DIR, 'extensions', - 'test-ext', + 'my-ext', ); mock({ [extensionDir]: { 'gemini-extension.json': JSON.stringify({ - name: 'test-ext', + name: 'my-ext', version: '1.0.0', }), commands: { - 'ext-cmd.toml': 'prompt = "Extension prompt"', + 'ext.toml': 'prompt = "Extension prompt"', }, }, }); @@ -1468,7 +1567,7 @@ describe('FileCommandLoader', () => { getProjectRoot: vi.fn(() => process.cwd()), getExtensions: vi.fn(() => [ { - name: 'test-ext', + name: 'my-ext', version: '1.0.0', isActive: true, path: extensionDir, @@ -1477,12 +1576,12 @@ describe('FileCommandLoader', () => { getFolderTrust: vi.fn(() => false), isTrustedFolder: vi.fn(() => false), } as unknown as Config; + const loader = new FileCommandLoader(mockConfig); const commands = await loader.loadCommands(signal); - const extCmd = commands.find((c) => c.name === 'ext-cmd'); - expect(extCmd).toBeDefined(); - expect(extCmd?.source).toBe('extension'); + const extCmd = commands.find((c) => c.name === 'ext'); + expect(extCmd?.description).toBe('[my-ext] Custom command from ext.toml'); }); }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index e858fd9ec85..f2138f73026 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as os from 'node:os'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import toml from '@iarna/toml'; @@ -17,7 +18,7 @@ import type { SlashCommand, SlashCommandActionReturn, } from '../ui/commands/types.js'; -import { CommandKind, CommandSource } from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js'; import type { IPromptProcessor, @@ -37,9 +38,9 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; + sourceLabel: string; extensionName?: string; extensionId?: string; - source: CommandSource; } /** @@ -68,11 +69,21 @@ export class FileCommandLoader implements ICommandLoader { private readonly projectRoot: string; private readonly folderTrustEnabled: boolean; private readonly isTrustedFolder: boolean; + private readonly username: string; constructor(private readonly config: Config | null) { this.folderTrustEnabled = !!config?.getFolderTrust(); this.isTrustedFolder = !!config?.isTrustedFolder(); this.projectRoot = config?.getProjectRoot() || process.cwd(); + this.username = this.getUsername(); + } + + private getUsername(): string { + try { + return os.userInfo().username; + } catch { + return process.env['USER'] || process.env['USERNAME'] || 'User'; + } } /** @@ -112,9 +123,9 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, + dirInfo.sourceLabel, dirInfo.extensionName, dirInfo.extensionId, - dirInfo.source, ), ); @@ -155,13 +166,13 @@ export class FileCommandLoader implements ICommandLoader { // 1. User commands dirs.push({ path: Storage.getUserCommandsDir(), - source: CommandSource.USER, + sourceLabel: this.username, }); // 2. Project commands (override user commands) dirs.push({ path: storage.getProjectCommandsDir(), - source: CommandSource.PROJECT, + sourceLabel: path.basename(this.projectRoot), }); // 3. Extension commands (processed last to detect all conflicts) @@ -173,9 +184,9 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), + sourceLabel: ext.name, extensionName: ext.name, extensionId: ext.id, - source: CommandSource.EXTENSION, })); dirs.push(...extensionCommandDirs); @@ -194,9 +205,9 @@ export class FileCommandLoader implements ICommandLoader { private async parseAndAdaptFile( filePath: string, baseDir: string, + sourceLabel: string, extensionName: string | undefined, extensionId: string | undefined, - source: CommandSource, ): Promise { let fileContent: string; try { @@ -255,15 +266,13 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); - // Add extension name tag for extension commands + // Add source label tag for all file-based commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; - let description = validDef.description || defaultDescription; - - description = sanitizeForDisplay(description, 100); - - if (extensionName) { - description = `[${extensionName}] ${description}`; - } + const rawDescription = validDef.description || defaultDescription; + const description = sanitizeForDisplay( + `[${sourceLabel}] ${rawDescription}`, + 100, + ); const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); @@ -299,7 +308,6 @@ export class FileCommandLoader implements ICommandLoader { kind: CommandKind.FILE, extensionName, extensionId, - source, action: async ( context: CommandContext, _args: string, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c2d65a77b1..2cbb9da9a7e 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -182,12 +182,6 @@ export enum CommandKind { AGENT = 'agent', } -export enum CommandSource { - USER = 'user', - PROJECT = 'project', - EXTENSION = 'extension', -} - // The standardized contract for any command in the system. export interface SlashCommand { name: string; @@ -196,7 +190,6 @@ export interface SlashCommand { hidden?: boolean; kind: CommandKind; - source?: CommandSource; /** * Controls whether the command auto-executes when selected with Enter. diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx index e87470bf6a9..6931268a379 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx @@ -7,7 +7,7 @@ import { render } from '../../test-utils/render.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { describe, it, expect } from 'vitest'; -import { CommandKind, CommandSource } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; describe('SuggestionsDisplay', () => { const mockSuggestions = [ @@ -121,40 +121,4 @@ describe('SuggestionsDisplay', () => { ); expect(lastFrame()).toMatchSnapshot(); }); - - it('renders source tags for file commands', () => { - const sourcedSuggestions = [ - { - label: 'User Cmd', - value: 'user-cmd', - commandKind: CommandKind.FILE, - source: CommandSource.USER, - }, - { - label: 'Project Cmd', - value: 'project-cmd', - commandKind: CommandKind.FILE, - source: CommandSource.PROJECT, - }, - { - label: 'Ext Cmd', - value: 'ext-cmd', - commandKind: CommandKind.FILE, - source: CommandSource.EXTENSION, - }, - ]; - - const { lastFrame } = render( - , - ); - expect(lastFrame()).toMatchSnapshot(); - }); }); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 9eae409ec3f..d9498e7a6bb 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -7,7 +7,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js'; -import { CommandKind, CommandSource } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; import { Colors } from '../colors.js'; import { sanitizeForDisplay } from '../utils/textUtils.js'; @@ -17,7 +17,6 @@ export interface Suggestion { description?: string; matchedIndex?: number; commandKind?: CommandKind; - source?: CommandSource; } interface SuggestionsDisplayProps { suggestions: Suggestion[]; @@ -68,16 +67,8 @@ export function SuggestionsDisplay({ [CommandKind.AGENT]: ' [Agent]', }; - const COMMAND_SOURCE_SUFFIX: Partial> = { - [CommandSource.USER]: ' [USER]', - [CommandSource.PROJECT]: ' [PROJECT]', - [CommandSource.EXTENSION]: ' [EXT]', - }; - const getFullLabel = (s: Suggestion) => - s.label + - (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '') + - (s.source ? (COMMAND_SOURCE_SUFFIX[s.source] ?? '') : ''); + s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : ''); const maxLabelLength = Math.max( ...suggestions.map((s) => getFullLabel(s).length), @@ -120,12 +111,6 @@ export function SuggestionsDisplay({ {COMMAND_KIND_SUFFIX[suggestion.commandKind]} )} - {suggestion.source && - COMMAND_SOURCE_SUFFIX[suggestion.source] && ( - - {COMMAND_SOURCE_SUFFIX[suggestion.source]} - - )} diff --git a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap index 5e625a1da03..ce1640ce257 100644 --- a/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap @@ -24,12 +24,6 @@ exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `" mcp-tool exports[`SuggestionsDisplay > renders loading state 1`] = `" Loading suggestions..."`; -exports[`SuggestionsDisplay > renders source tags for file commands 1`] = ` -" user-cmd [USER] - project-cmd [PROJECT] - ext-cmd [EXT]" -`; - exports[`SuggestionsDisplay > renders suggestions list 1`] = ` " command1 Description 1 command2 Description 2 diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 79abd8e866f..ea320b80a13 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -1108,134 +1108,6 @@ describe('useSlashCompletion', () => { }); }); - describe('Command Source Information', () => { - it('should include source for file commands in suggestions', async () => { - const slashCommands = [ - { - name: 'user-cmd', - description: 'User command', - kind: CommandKind.FILE, - source: 'user', - action: vi.fn(), - }, - { - name: 'project-cmd', - description: 'Project command', - kind: CommandKind.FILE, - source: 'project', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; - - const { result, unmount } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/', - slashCommands, - mockCommandContext, - ), - ); - - await waitFor(() => { - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'user-cmd', - value: 'user-cmd', - description: 'User command', - commandKind: CommandKind.FILE, - source: 'user', - }, - { - label: 'project-cmd', - value: 'project-cmd', - description: 'Project command', - commandKind: CommandKind.FILE, - source: 'project', - }, - ]), - ); - }); - unmount(); - }); - - it('should include source when filtering commands by prefix', async () => { - const slashCommands = [ - { - name: 'deploy', - description: 'Deploy project', - kind: CommandKind.FILE, - source: 'project', - action: vi.fn(), - }, - ] as unknown as SlashCommand[]; - - const { result, unmount } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/dep', - slashCommands, - mockCommandContext, - ), - ); - - await waitFor(() => { - expect(result.current.suggestions).toEqual([ - { - label: 'deploy', - value: 'deploy', - description: 'Deploy project', - commandKind: CommandKind.FILE, - source: 'project', - }, - ]); - }); - unmount(); - }); - - it('should include source for sub-commands', async () => { - const slashCommands = [ - { - name: 'ext', - description: 'Extension parent', - kind: CommandKind.FILE, - source: 'extension', - subCommands: [ - { - name: 'cmd', - description: 'Extension command', - kind: CommandKind.FILE, - source: 'extension', - action: vi.fn(), - }, - ], - }, - ] as unknown as SlashCommand[]; - - const { result, unmount } = renderHook(() => - useTestHarnessForSlashCompletion( - true, - '/ext ', - slashCommands, - mockCommandContext, - ), - ); - - await waitFor(() => { - expect(result.current.suggestions).toEqual([ - { - label: 'cmd', - value: 'cmd', - description: 'Extension command', - commandKind: CommandKind.FILE, - source: 'extension', - }, - ]); - }); - unmount(); - }); - }); - it('should not call shared callbacks when disabled', async () => { const mockSetSuggestions = vi.fn(); const mockSetIsLoadingSuggestions = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index c0bbf0f055e..7809d6cf0fe 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -316,7 +316,6 @@ function useCommandSuggestions( value: cmd.name, description: cmd.description, commandKind: cmd.kind, - source: cmd.source, })); setSuggestions(finalSuggestions); From f7f2637d804fe97da0342daecc8e00e04214e07e Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 18 Feb 2026 14:09:19 -0500 Subject: [PATCH 3/4] Move getUsername method to a utills file. Parametrize some unit tests to reduce code duplication. --- .../src/services/FileCommandLoader.test.ts | 161 +++++------------- .../cli/src/services/FileCommandLoader.ts | 12 +- packages/cli/src/utils/osUtils.test.ts | 74 ++++++++ packages/cli/src/utils/osUtils.ts | 20 +++ 4 files changed, 141 insertions(+), 126 deletions(-) create mode 100644 packages/cli/src/utils/osUtils.test.ts create mode 100644 packages/cli/src/utils/osUtils.ts diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index dba469efb05..cefe4ad6fc6 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -6,7 +6,6 @@ import * as glob from 'glob'; import * as path from 'node:path'; -import * as os from 'node:os'; import type { Config } from '@google/gemini-cli-core'; import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; import mock from 'mock-fs'; @@ -33,6 +32,9 @@ vi.mock('./prompt-processors/atFileProcessor.js', () => ({ process: mockAtFileProcess, })), })); +vi.mock('../utils/osUtils.js', () => ({ + getUsername: vi.fn().mockReturnValue('mock-user'), +})); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, @@ -76,14 +78,6 @@ vi.mock('glob', () => ({ glob: vi.fn(), })); -vi.mock('node:os', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - userInfo: vi.fn().mockReturnValue({ username: 'mock-user' }), - }; -}); - describe('FileCommandLoader', () => { const signal: AbortSignal = new AbortController().signal; @@ -1419,7 +1413,7 @@ describe('FileCommandLoader', () => { }); describe('sourceLabel Assignment', () => { - it('uses the OS username for user commands', async () => { + it('uses the username for user commands', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { @@ -1435,113 +1429,48 @@ describe('FileCommandLoader', () => { ); }); - it('falls back to environment variables or "User" if os.userInfo() fails', async () => { - const userInfoSpy = vi.spyOn(os, 'userInfo').mockImplementation(() => { - throw new Error('userInfo failed'); - }); - - const userCommandsDir = Storage.getUserCommandsDir(); - mock({ - [userCommandsDir]: { - 'test.toml': 'prompt = "Fallback prompt"', - }, - }); - - // Falls back to 'USER' environment variable - vi.stubEnv('USER', 'env-user'); - const loader = new FileCommandLoader(null); - const commands = await loader.loadCommands(signal); - expect(commands[0].description).toBe( - '[env-user] Custom command from test.toml', - ); - - // Falls back to 'USERNAME' environment variable - vi.stubEnv('USER', ''); - vi.stubEnv('USERNAME', 'win-user'); - const loader2 = new FileCommandLoader(null); - const commands2 = await loader2.loadCommands(signal); - expect(commands2[0].description).toBe( - '[win-user] Custom command from test.toml', - ); - - // Falls back to 'User' string - vi.stubEnv('USERNAME', ''); - const loader3 = new FileCommandLoader(null); - const commands3 = await loader3.loadCommands(signal); - expect(commands3[0].description).toBe( - '[User] Custom command from test.toml', - ); - - userInfoSpy.mockRestore(); - vi.unstubAllEnvs(); - }); - - it('uses the project root basename for project commands', async () => { - const projectRoot = '/path/to/my-awesome-project'; - const projectCommandsDir = path.join(projectRoot, GEMINI_DIR, 'commands'); - - mock({ - [projectCommandsDir]: { - 'project.toml': 'prompt = "Project prompt"', - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => projectRoot), - getExtensions: vi.fn(() => []), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - storage: new Storage(projectRoot), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); - - const projectCmd = commands.find((c) => c.name === 'project'); - expect(projectCmd?.description).toBe( - '[my-awesome-project] Custom command from project.toml', - ); - }); - - it('correctly handles Windows-style project root paths', async () => { - // Simulate a Windows path regardless of the current OS - const windowsProjectRoot = 'C:\\Users\\test\\projects\\win-project'; - // We use path.win32 to explicitly test Windows behavior - const expectedLabel = path.win32.basename(windowsProjectRoot); - expect(expectedLabel).toBe('win-project'); - - const projectCommandsDir = path.join( - windowsProjectRoot, - GEMINI_DIR, - 'commands', - ); - - mock({ - [projectCommandsDir]: { - 'win-cmd.toml': 'prompt = "Win prompt"', - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => windowsProjectRoot), - getExtensions: vi.fn(() => []), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - storage: new Storage(windowsProjectRoot), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); + it.each([ + { + name: 'standard path', + projectRoot: '/path/to/my-awesome-project', + }, + { + name: 'Windows-style path', + projectRoot: 'C:\\Users\\test\\projects\\win-project', + }, + ])( + 'uses the project root basename for project commands ($name)', + async ({ projectRoot }) => { + const expectedLabel = path.basename(projectRoot); + const projectCommandsDir = path.join( + projectRoot, + GEMINI_DIR, + 'commands', + ); - const winCmd = commands.find((c) => c.name === 'win-cmd'); - // On non-Windows systems, path.basename might not handle \ correctly - // but FileCommandLoader uses the platform-native 'path' module. - // In the test we just want to ensure it uses WHATEVER path.basename returns. - const actualLabel = path.basename(windowsProjectRoot); - expect(winCmd?.description).toBe( - `[${actualLabel}] Custom command from win-cmd.toml`, - ); - }); + mock({ + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => projectRoot), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + storage: new Storage(projectRoot), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const projectCmd = commands.find((c) => c.name === 'project'); + expect(projectCmd?.description).toBe( + `[${expectedLabel}] Custom command from project.toml`, + ); + }, + ); it('uses the extension name for extension commands', async () => { const extensionDir = path.join( diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index f2138f73026..996e650678a 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as os from 'node:os'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import toml from '@iarna/toml'; @@ -35,6 +34,7 @@ import { } from './prompt-processors/shellProcessor.js'; import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; +import { getUsername } from '../utils/osUtils.js'; interface CommandDirectory { path: string; @@ -75,15 +75,7 @@ export class FileCommandLoader implements ICommandLoader { this.folderTrustEnabled = !!config?.getFolderTrust(); this.isTrustedFolder = !!config?.isTrustedFolder(); this.projectRoot = config?.getProjectRoot() || process.cwd(); - this.username = this.getUsername(); - } - - private getUsername(): string { - try { - return os.userInfo().username; - } catch { - return process.env['USER'] || process.env['USERNAME'] || 'User'; - } + this.username = getUsername(); } /** diff --git a/packages/cli/src/utils/osUtils.test.ts b/packages/cli/src/utils/osUtils.test.ts new file mode 100644 index 00000000000..3c39d37a05a --- /dev/null +++ b/packages/cli/src/utils/osUtils.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as os from 'node:os'; +import { getUsername } from './osUtils.js'; + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, userInfo: vi.fn() }; +}); + +describe('getUsername', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it.each([ + { + source: 'os.userInfo().username', + userInfo: 'os-user', + user: 'ignored', + username: 'ignored', + expected: 'os-user', + }, + { + source: 'USER environment variable', + userInfo: null, + user: 'env-user', + username: 'ignored', + expected: 'env-user', + }, + { + source: 'USERNAME environment variable', + userInfo: null, + user: '', + username: 'win-user', + expected: 'win-user', + }, + { + source: 'the default "User" string', + userInfo: null, + user: '', + username: '', + expected: 'User', + }, + ])( + 'prefers $source when available', + ({ userInfo, user, username, expected }) => { + if (userInfo) { + vi.mocked(os.userInfo).mockReturnValue({ + username: userInfo, + uid: 0, + gid: 0, + homedir: '', + shell: '', + }); + } else { + vi.mocked(os.userInfo).mockImplementation(() => { + throw new Error('userInfo failed'); + }); + } + + vi.stubEnv('USER', user); + vi.stubEnv('USERNAME', username); + + expect(getUsername()).toBe(expected); + }, + ); +}); diff --git a/packages/cli/src/utils/osUtils.ts b/packages/cli/src/utils/osUtils.ts new file mode 100644 index 00000000000..bc4618365ba --- /dev/null +++ b/packages/cli/src/utils/osUtils.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'node:os'; + +/** + * Retrieves the current user's username with fallbacks for different environments. + * + * @returns The username string, falling back to environment variables or 'User'. + */ +export function getUsername(): string { + try { + return os.userInfo().username; + } catch { + return process.env['USER'] || process.env['USERNAME'] || 'User'; + } +} From 8a96b1876c006ee3f6d50289d804569fc2d64c96 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Thu, 19 Feb 2026 18:05:25 -0500 Subject: [PATCH 4/4] Add a "user", "workspace", or extension name prefix to the command name instead. --- .../cli/src/services/CommandService.test.ts | 141 ++++++++++-------- packages/cli/src/services/CommandService.ts | 121 ++++++++++----- .../src/services/FileCommandLoader.test.ts | 47 +++--- .../cli/src/services/FileCommandLoader.ts | 26 ++-- packages/cli/src/ui/commands/types.ts | 6 + packages/cli/src/utils/osUtils.test.ts | 74 --------- packages/cli/src/utils/osUtils.ts | 20 --- 7 files changed, 198 insertions(+), 237 deletions(-) delete mode 100644 packages/cli/src/utils/osUtils.test.ts delete mode 100644 packages/cli/src/utils/osUtils.ts diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index ea906a3da6b..6d888d4b2de 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,8 +10,13 @@ import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; -const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ +const createMockCommand = ( + name: string, + kind: CommandKind, + namespace?: string, +): SlashCommand => ({ name, + namespace, description: `Description for ${name}`, kind, action: vi.fn(), @@ -179,18 +184,18 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); - it('should rename extension commands when they conflict', async () => { + it('should apply namespaces to commands from user and extensions', async () => { const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE); + const userCommand = createMockCommand('sync', CommandKind.FILE, 'user'); const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', + description: 'Deploy to Firebase', }; const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE), + ...createMockCommand('sync', CommandKind.FILE, 'git-helper'), extensionName: 'git-helper', - description: '[git-helper] Sync with remote', + description: 'Sync with remote', }; const mockLoader1 = new MockCommandLoader([builtinCommand]); @@ -208,30 +213,28 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(4); - // Built-in command keeps original name + // Built-in command keeps original name because it has no namespace const deployBuiltin = commands.find( (cmd) => cmd.name === 'deploy' && !cmd.extensionName, ); expect(deployBuiltin).toBeDefined(); expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - // Extension command conflicting with built-in gets renamed + // Extension command gets namespaced, preventing conflict with built-in const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase.deploy', + (cmd) => cmd.name === 'firebase:deploy', ); expect(deployExtension).toBeDefined(); expect(deployExtension?.extensionName).toBe('firebase'); - // User command keeps original name - const syncUser = commands.find( - (cmd) => cmd.name === 'sync' && !cmd.extensionName, - ); + // User command gets namespaced + const syncUser = commands.find((cmd) => cmd.name === 'user:sync'); expect(syncUser).toBeDefined(); expect(syncUser?.kind).toBe(CommandKind.FILE); - // Extension command conflicting with user command gets renamed + // Extension command gets namespaced const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper.sync', + (cmd) => cmd.name === 'git-helper:sync', ); expect(syncExtension).toBeDefined(); expect(syncExtension?.extensionName).toBe('git-helper'); @@ -269,16 +272,16 @@ describe('CommandService', () => { expect(deployCommand?.kind).toBe(CommandKind.FILE); }); - it('should handle secondary conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp.deploy commands + it('should handle namespaced name conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp:deploy commands const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); - // Extension also has a deploy command that will conflict with user's /deploy + // Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2 const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -301,31 +304,31 @@ describe('CommandService', () => { ); expect(deployUser).toBeDefined(); - // User's dot notation command keeps its name + // User's command keeps its name const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, + (cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName, ); expect(gcpDeployUser).toBeDefined(); - // Extension command gets renamed with suffix due to secondary conflict + // Extension command gets renamed with suffix due to namespaced name conflict const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp.deploy, and /gcp.deploy1 + it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp:deploy, and /gcp:deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE); - // Extension has a deploy command + // Extension has a deploy command which resolves to /gcp:deploy const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -345,16 +348,19 @@ describe('CommandService', () => { // Extension command gets renamed with suffix 2 due to multiple conflicts const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should report conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report extension namespaced name conflicts via getConflicts', async () => { + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; @@ -372,29 +378,29 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: builtinCommand, losers: [ { - renamedTo: 'firebase.deploy', + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'firebase', + namespace: 'firebase', }), }, ], }); }); - it('should report extension vs extension conflicts correctly', async () => { - // Both extensions try to register 'deploy' + it('should report extension vs extension namespaced name conflicts correctly', async () => { + // Both extensions try to register 'firebase:deploy' const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'aws', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([ @@ -411,32 +417,37 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: expect.objectContaining({ - name: 'deploy', + name: 'firebase:deploy', extensionName: 'firebase', }), losers: [ { - renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'aws', + extensionName: 'firebase', }), }, ], }); }); - it('should report multiple conflicts for the same command name', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report multiple extension namespaced name conflicts for the same name', async () => { + // Built-in command is 'firebase:deploy' + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); + // Two extension commands from extension 'firebase' also try to be 'firebase:deploy' const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext1', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext2', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); @@ -448,17 +459,23 @@ describe('CommandService', () => { const conflicts = service.getConflicts(); expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('deploy'); + expect(conflicts[0].name).toBe('firebase:deploy'); expect(conflicts[0].losers).toHaveLength(2); expect(conflicts[0].losers).toEqual( expect.arrayContaining([ expect.objectContaining({ - renamedTo: 'ext1.deploy', - command: expect.objectContaining({ extensionName: 'ext1' }), + renamedTo: 'firebase:deploy1', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), expect.objectContaining({ - renamedTo: 'ext2.deploy', - command: expect.objectContaining({ extensionName: 'ext2' }), + renamedTo: 'firebase:deploy2', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), ]), ); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index bd42226a32c..570bfee36ff 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -79,61 +79,100 @@ export class CommandService { const conflictsMap = new Map(); for (const cmd of allCommands) { - let finalName = cmd.name; - + let fullName = this.resolveFullName(cmd); // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(cmd.name)) { - const winner = commandMap.get(cmd.name)!; - let renamedName = `${cmd.extensionName}.${cmd.name}`; - let suffix = 1; - - // Keep trying until we find a name that doesn't conflict - while (commandMap.has(renamedName)) { - renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; - suffix++; - } - - finalName = renamedName; - - if (!conflictsMap.has(cmd.name)) { - conflictsMap.set(cmd.name, { - name: cmd.name, - winner, - losers: [], - }); - } - - conflictsMap.get(cmd.name)!.losers.push({ - command: cmd, - renamedTo: finalName, - }); + if (cmd.extensionName && commandMap.has(fullName)) { + fullName = this.resolveConflict( + fullName, + cmd, + commandMap, + conflictsMap, + ); } - commandMap.set(finalName, { + commandMap.set(fullName, { ...cmd, - name: finalName, + name: fullName, }); } const conflicts = Array.from(conflictsMap.values()); - if (conflicts.length > 0) { - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } + this.emitConflicts(conflicts); const finalCommands = Object.freeze(Array.from(commandMap.values())); const finalConflicts = Object.freeze(conflicts); return new CommandService(finalCommands, finalConflicts); } + /** + * Prepends the namespace to the command name if provided and not already present. + */ + private static resolveFullName(cmd: SlashCommand): string { + if (!cmd.namespace) { + return cmd.name; + } + + const prefix = `${cmd.namespace}:`; + return cmd.name.startsWith(prefix) ? cmd.name : `${prefix}${cmd.name}`; + } + + /** + * Resolves a naming conflict by generating a unique name for an extension command. + * Also records the conflict for reporting. + */ + private static resolveConflict( + fullName: string, + cmd: SlashCommand, + commandMap: Map, + conflictsMap: Map, + ): string { + const winner = commandMap.get(fullName)!; + let renamedName = fullName; + let suffix = 1; + + // Generate a unique name by appending an incrementing numeric suffix. + while (commandMap.has(renamedName)) { + renamedName = `${fullName}${suffix}`; + suffix++; + } + + // Record the conflict details for downstream reporting. + if (!conflictsMap.has(fullName)) { + conflictsMap.set(fullName, { + name: fullName, + winner, + losers: [], + }); + } + + conflictsMap.get(fullName)!.losers.push({ + command: cmd, + renamedTo: renamedName, + }); + + return renamedName; + } + + /** + * Emits conflict events for all detected collisions. + */ + private static emitConflicts(conflicts: CommandConflict[]): void { + if (conflicts.length === 0) { + return; + } + + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: c.winner.extensionName, + })), + ), + ); + } + /** * Retrieves the currently loaded and de-duplicated list of slash commands. * diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index cefe4ad6fc6..4a92543add6 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -385,9 +385,7 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); - expect(command.description).toBe( - '[mock-user] Custom command from test.toml', - ); + expect(command.description).toBe('Custom command from test.toml'); }); it('uses the provided description', async () => { @@ -402,7 +400,7 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const command = commands[0]; expect(command).toBeDefined(); - expect(command.description).toBe('[mock-user] My test command'); + expect(command.description).toBe('My test command'); }); it('should sanitize colons in filenames to prevent namespace conflicts', async () => { @@ -587,7 +585,7 @@ describe('FileCommandLoader', () => { const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); - expect(extCommand?.description).toMatch(/^\[test-ext\]/); + expect(extCommand?.description).toBe('Custom command from ext.toml'); }); it('extension commands have extensionName metadata for conflict resolution', async () => { @@ -675,7 +673,7 @@ describe('FileCommandLoader', () => { expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); - expect(commands[2].description).toMatch(/^\[test-ext\]/); + expect(commands[2].description).toBe('Custom command from deploy.toml'); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { @@ -752,7 +750,7 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); - expect(commands[0].description).toMatch(/^\[active-ext\]/); + expect(commands[0].description).toBe('Custom command from active.toml'); }); it('handles missing extension commands directory gracefully', async () => { @@ -835,7 +833,7 @@ describe('FileCommandLoader', () => { const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); - expect(nestedCmd?.description).toMatch(/^\[a\]/); + expect(nestedCmd?.description).toBe('Custom command from c.toml'); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ @@ -1387,10 +1385,8 @@ describe('FileCommandLoader', () => { const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); - // Newlines and tabs become spaces, ANSI is stripped, prepended with [mock-user] - expect(commands[0].description).toBe( - '[mock-user] Line 1 Line 2 Tabbed Red text', - ); + // Newlines and tabs become spaces, ANSI is stripped + expect(commands[0].description).toBe('Line 1 Line 2 Tabbed Red text'); }); it('truncates long descriptions', async () => { @@ -1406,14 +1402,12 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); expect(commands).toHaveLength(1); expect(commands[0].description.length).toBe(100); - expect(commands[0].description).toBe( - '[mock-user] ' + 'd'.repeat(85) + '...', - ); + expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); - describe('sourceLabel Assignment', () => { - it('uses the username for user commands', async () => { + describe('command namespace', () => { + it('is "user" for user commands', async () => { const userCommandsDir = Storage.getUserCommandsDir(); mock({ [userCommandsDir]: { @@ -1424,9 +1418,9 @@ describe('FileCommandLoader', () => { const loader = new FileCommandLoader(null); const commands = await loader.loadCommands(signal); - expect(commands[0].description).toBe( - '[mock-user] Custom command from test.toml', - ); + expect(commands[0].name).toBe('test'); + expect(commands[0].namespace).toBe('user'); + expect(commands[0].description).toBe('Custom command from test.toml'); }); it.each([ @@ -1439,9 +1433,8 @@ describe('FileCommandLoader', () => { projectRoot: 'C:\\Users\\test\\projects\\win-project', }, ])( - 'uses the project root basename for project commands ($name)', + 'is "workspace" for project commands ($name)', async ({ projectRoot }) => { - const expectedLabel = path.basename(projectRoot); const projectCommandsDir = path.join( projectRoot, GEMINI_DIR, @@ -1466,13 +1459,15 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const projectCmd = commands.find((c) => c.name === 'project'); + expect(projectCmd).toBeDefined(); + expect(projectCmd?.namespace).toBe('workspace'); expect(projectCmd?.description).toBe( - `[${expectedLabel}] Custom command from project.toml`, + `Custom command from project.toml`, ); }, ); - it('uses the extension name for extension commands', async () => { + it('is the extension name for extension commands', async () => { const extensionDir = path.join( process.cwd(), GEMINI_DIR, @@ -1510,7 +1505,9 @@ describe('FileCommandLoader', () => { const commands = await loader.loadCommands(signal); const extCmd = commands.find((c) => c.name === 'ext'); - expect(extCmd?.description).toBe('[my-ext] Custom command from ext.toml'); + expect(extCmd).toBeDefined(); + expect(extCmd?.namespace).toBe('my-ext'); + expect(extCmd?.description).toBe('Custom command from ext.toml'); }); }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 996e650678a..ea46efbfec4 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -34,11 +34,10 @@ import { } from './prompt-processors/shellProcessor.js'; import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; -import { getUsername } from '../utils/osUtils.js'; interface CommandDirectory { path: string; - sourceLabel: string; + namespace: string; extensionName?: string; extensionId?: string; } @@ -69,13 +68,11 @@ export class FileCommandLoader implements ICommandLoader { private readonly projectRoot: string; private readonly folderTrustEnabled: boolean; private readonly isTrustedFolder: boolean; - private readonly username: string; constructor(private readonly config: Config | null) { this.folderTrustEnabled = !!config?.getFolderTrust(); this.isTrustedFolder = !!config?.isTrustedFolder(); this.projectRoot = config?.getProjectRoot() || process.cwd(); - this.username = getUsername(); } /** @@ -115,7 +112,7 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, - dirInfo.sourceLabel, + dirInfo.namespace, dirInfo.extensionName, dirInfo.extensionId, ), @@ -158,13 +155,13 @@ export class FileCommandLoader implements ICommandLoader { // 1. User commands dirs.push({ path: Storage.getUserCommandsDir(), - sourceLabel: this.username, + namespace: 'user', }); // 2. Project commands (override user commands) dirs.push({ path: storage.getProjectCommandsDir(), - sourceLabel: path.basename(this.projectRoot), + namespace: 'workspace', }); // 3. Extension commands (processed last to detect all conflicts) @@ -176,7 +173,7 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), - sourceLabel: ext.name, + namespace: ext.name, extensionName: ext.name, extensionId: ext.id, })); @@ -191,13 +188,14 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param namespace The namespace of the command. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, - sourceLabel: string, + namespace: string, extensionName: string | undefined, extensionId: string | undefined, ): Promise { @@ -258,13 +256,10 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); - // Add source label tag for all file-based commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; - const rawDescription = validDef.description || defaultDescription; - const description = sanitizeForDisplay( - `[${sourceLabel}] ${rawDescription}`, - 100, - ); + let description = validDef.description || defaultDescription; + + description = sanitizeForDisplay(description, 100); const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); @@ -296,6 +291,7 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, + namespace, description, kind: CommandKind.FILE, extensionName, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7e..11029cd2f44 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -191,6 +191,12 @@ export interface SlashCommand { kind: CommandKind; + /** + * Optional namespace for the command (e.g., 'user', 'workspace', 'extensionName'). + * If provided, the command will be registered as 'namespace:name'. + */ + namespace?: string; + /** * Controls whether the command auto-executes when selected with Enter. * diff --git a/packages/cli/src/utils/osUtils.test.ts b/packages/cli/src/utils/osUtils.test.ts deleted file mode 100644 index 3c39d37a05a..00000000000 --- a/packages/cli/src/utils/osUtils.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import * as os from 'node:os'; -import { getUsername } from './osUtils.js'; - -vi.mock('node:os', async (importOriginal) => { - const original = await importOriginal(); - return { ...original, userInfo: vi.fn() }; -}); - -describe('getUsername', () => { - afterEach(() => { - vi.clearAllMocks(); - vi.unstubAllEnvs(); - }); - - it.each([ - { - source: 'os.userInfo().username', - userInfo: 'os-user', - user: 'ignored', - username: 'ignored', - expected: 'os-user', - }, - { - source: 'USER environment variable', - userInfo: null, - user: 'env-user', - username: 'ignored', - expected: 'env-user', - }, - { - source: 'USERNAME environment variable', - userInfo: null, - user: '', - username: 'win-user', - expected: 'win-user', - }, - { - source: 'the default "User" string', - userInfo: null, - user: '', - username: '', - expected: 'User', - }, - ])( - 'prefers $source when available', - ({ userInfo, user, username, expected }) => { - if (userInfo) { - vi.mocked(os.userInfo).mockReturnValue({ - username: userInfo, - uid: 0, - gid: 0, - homedir: '', - shell: '', - }); - } else { - vi.mocked(os.userInfo).mockImplementation(() => { - throw new Error('userInfo failed'); - }); - } - - vi.stubEnv('USER', user); - vi.stubEnv('USERNAME', username); - - expect(getUsername()).toBe(expected); - }, - ); -}); diff --git a/packages/cli/src/utils/osUtils.ts b/packages/cli/src/utils/osUtils.ts deleted file mode 100644 index bc4618365ba..00000000000 --- a/packages/cli/src/utils/osUtils.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as os from 'node:os'; - -/** - * Retrieves the current user's username with fallbacks for different environments. - * - * @returns The username string, falling back to environment variables or 'User'. - */ -export function getUsername(): string { - try { - return os.userInfo().username; - } catch { - return process.env['USER'] || process.env['USERNAME'] || 'User'; - } -}