From 39361bce1f48a84ba05fc62f70f20d4151400252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mois=C3=A9s=20Gana=20Obreg=C3=B3n?= <78716364+moisgobg@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:31:52 -0700 Subject: [PATCH] feat(cli): add support for the `${extensionPath}` variable in extension TOML commands (#22681) --- docs/cli/custom-commands.md | 19 ++++++ .../src/services/FileCommandLoader.test.ts | 60 +++++++++++++++++++ .../cli/src/services/FileCommandLoader.ts | 12 ++++ .../conductor/commands/conductor/setup.toml | 6 +- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index dd2698290e..5cb976da7d 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -267,6 +267,25 @@ When you run `/review FileCommandLoader.ts`, the `@{docs/best-practices.md}` placeholder is replaced by the content of that file, and `{{args}}` is replaced by the text you provided, before the final prompt is sent to the model. +### 5. Using extension paths with `${extensionPath}` + +If you are authoring a custom command as part of an +[extension](../extensions/index.md), you can use the `${extensionPath}` variable +in your prompt. This allows your command to reference files or resources bundled +within the extension directory, regardless of where the extension is installed. + +**How it works:** + +- `${extensionPath}` is replaced with the absolute path to your extension's root + directory before any other processing (like `!{...}` or `@{...}`) occurs. + +**Example (`/my-extension.toml`):** + +```toml +description = "Uses a local template from the extension." +prompt = "Please format the code according to this template: ${extensionPath}/templates/code-style.md" +``` + --- ## Example: A "Pure Function" refactoring command diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index f3f8c2df94..6645fe6f09 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -895,6 +895,66 @@ describe('FileCommandLoader', () => { expect(command.extensionName).toBe('my-test-ext'); expect(command.extensionId).toBe(extensionId); }); + + it('correctly injects ${extensionPath} into extension commands', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'path-test-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'path-test-ext', + version: '1.0.0', + }), + commands: { + 'path-cmd.toml': 'prompt = "Path: ${extensionPath}/templates"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'path-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); + + expect(commands).toHaveLength(1); + const command = commands[0]; + expect(command.name).toBe('path-cmd'); + + const result = await command.action?.( + createMockCommandContext({ + invocation: { + raw: '/path-cmd', + name: 'path-cmd', + args: '', + }, + }), + '', + ); + + if (result?.type === 'submit_prompt') { + expect(result.content).toEqual([ + { text: `Path: ${extensionDir}/templates` }, + ]); + } else { + assert.fail('Incorrect action type'); + } + }); }); describe('Argument Handling Integration (via ShellProcessor)', () => { diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index 7321837c93..ee08383263 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -39,6 +39,7 @@ interface CommandDirectory { kind: CommandKind; extensionName?: string; extensionId?: string; + extensionPath?: string; } /** @@ -114,6 +115,7 @@ export class FileCommandLoader implements ICommandLoader { dirInfo.kind, dirInfo.extensionName, dirInfo.extensionId, + dirInfo.extensionPath, ), ); @@ -175,6 +177,7 @@ export class FileCommandLoader implements ICommandLoader { kind: CommandKind.EXTENSION_FILE, extensionName: ext.name, extensionId: ext.id, + extensionPath: ext.path, })); dirs.push(...extensionCommandDirs); @@ -197,6 +200,7 @@ export class FileCommandLoader implements ICommandLoader { kind: CommandKind, extensionName?: string, extensionId?: string, + extensionPath?: string, ): Promise { let fileContent: string; try { @@ -235,6 +239,14 @@ export class FileCommandLoader implements ICommandLoader { const validDef = validationResult.data; + // Hydrate extensionPath if this is an extension command + if (extensionPath) { + validDef.prompt = validDef.prompt.replace( + /\$\{extensionPath\}/g, + () => extensionPath, + ); + } + const relativePathWithExt = path.relative(baseDir, filePath); const relativePath = relativePathWithExt.substring( 0, diff --git a/packages/core/src/extensions/builtin/conductor/commands/conductor/setup.toml b/packages/core/src/extensions/builtin/conductor/commands/conductor/setup.toml index 5d6fed8925..27df65d2b0 100644 --- a/packages/core/src/extensions/builtin/conductor/commands/conductor/setup.toml +++ b/packages/core/src/extensions/builtin/conductor/commands/conductor/setup.toml @@ -285,7 +285,7 @@ PLAN MODE PROTOCOL: This setup process runs entirely within Plan Mode. While in ### 2.4 Select Guides (Interactive) 1. **Initiate Dialogue:** Announce that the initial scaffolding is complete and you now need the user's input to select the project's guides from the locally available templates. 2. **Select Code Style Guides:** - - List the available style guides by using the `run_shell_command` tool to execute `ls ~/.gemini/extensions/conductor/templates/code_styleguides/`. **CRITICAL: You MUST use `run_shell_command` for this step. Do NOT use the `list_directory` tool, as the templates directory resides outside of your allowed workspace and the call will fail.** + - List the available style guides by using the `run_shell_command` tool to execute `ls ${extensionPath}/templates/code_styleguides/`. **CRITICAL: You MUST use `run_shell_command` for this step. Do NOT use the `list_directory` tool, as the templates directory resides outside of your allowed workspace and the call will fail.** - **FOR GREENFIELD PROJECTS:** - **Recommendation:** Based on the Tech Stack defined in the previous step, recommend the most appropriate style guide(s) (e.g., "python.md" for a Python project) and explain why. - **Determine Mode:** Use the `ask_user` tool: @@ -322,12 +322,12 @@ PLAN MODE PROTOCOL: This setup process runs entirely within Plan Mode. While in - **Action:** Announce "I'll present the additional guides. Please select all that apply." Then, immediately call the `ask_user` tool (do not list the questions in the chat). - **Method:** Use a single `ask_user` tool call. Dynamically split the available guides into batches of 4 options max. Create one `multiSelect: true` question for each batch. -3. **Action:** Construct and execute a command to create the directory and copy all selected files. For example: `mkdir -p conductor/code_styleguides && cp ~/.gemini/extensions/conductor/templates/code_styleguides/python.md ~/.gemini/extensions/conductor/templates/code_styleguides/javascript.md conductor/code_styleguides/` +3. **Action:** Construct and execute a command to create the directory and copy all selected files. For example: `mkdir -p conductor/code_styleguides && cp ${extensionPath}/templates/code_styleguides/python.md ${extensionPath}/templates/code_styleguides/javascript.md conductor/code_styleguides/` 4. **Continue:** Immediately proceed to the next section. ### 2.5 Select Workflow (Interactive) 1. **Copy Initial Workflow:** - - Copy `~/.gemini/extensions/conductor/templates/workflow.md` to `conductor/workflow.md`. + - Copy `${extensionPath}/templates/workflow.md` to `conductor/workflow.md`. 2. **Determine Mode:** Use the `ask_user` tool to let the user choose their preferred workflow. - **questions:** - **header:** "Workflow"