diff --git a/.gemini/commands/test-cmd.toml b/.gemini/commands/test-cmd.toml new file mode 100644 index 00000000000..c82e918b6d1 --- /dev/null +++ b/.gemini/commands/test-cmd.toml @@ -0,0 +1,2 @@ +description = "A test command" +prompt = "What is your favorite color?" diff --git a/package.json b/package.json index 44e7fc42638..ffba312910a 100644 --- a/package.json +++ b/package.json @@ -61,13 +61,6 @@ "clean": "node scripts/clean.js", "pre-commit": "node scripts/pre-commit.js" }, - "overrides": { - "ink": "npm:@jrichman/ink@6.4.6", - "wrap-ansi": "9.0.2", - "cliui": { - "wrap-ansi": "7.0.0" - } - }, "bin": { "gemini": "bundle/gemini.js" }, diff --git a/packages/cli/src/commands/custom.ts b/packages/cli/src/commands/custom.ts new file mode 100644 index 00000000000..5f836ab9540 --- /dev/null +++ b/packages/cli/src/commands/custom.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { glob } from 'glob'; +import type { CommandModule } from 'yargs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import toml from '@iarna/toml'; +import { spawn } from 'node:child_process'; + +async function findGeminiDir(): Promise { + let currentDir = process.cwd(); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const geminiDir = path.join(currentDir, '.gemini'); + try { + const stats = await fs.stat(geminiDir); + if (stats.isDirectory()) { + return geminiDir; + } + } catch (_error) { + // Ignore error if .gemini doesn't exist + } + currentDir = path.dirname(currentDir); + } + return null; +} + +async function createCommandFromFile( + filePath: string, + baseDir: string, +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = toml.parse(content); + const commandName = path + .relative(baseDir, filePath) + .replace(/\\/g, '/') + .replace(/\.toml$/, ''); + const description = (parsed as { description?: unknown }).description; + const prompt = (parsed as { prompt?: unknown }).prompt; + + if (typeof description !== 'string' || !description) { + console.error(`Description is missing or not a string in ${filePath}`); + return null; + } + + if (typeof prompt !== 'string' || !prompt) { + console.error(`Prompt is missing or not a string in ${filePath}`); + return null; + } + + const handler = () => { + const child = spawn(process.execPath, [process.argv[1], '-p', prompt], { + stdio: 'inherit', + }); + + child.on('close', (code) => { + process.exit(code ?? 0); + }); + }; + + return { + command: commandName, + describe: description, + handler, + }; + } catch (_error) { + // Log error for debugging, but don't crash + console.error(`Failed to load custom command from ${filePath}:`, _error); + return null; + } +} + +export async function loadCustomCommands(): Promise { + const geminiDir = await findGeminiDir(); + + if (!geminiDir) { + return []; + } + + const commandsDir = path.join(geminiDir, 'commands'); + + try { + const files = await glob('**/*.toml', { cwd: commandsDir, nodir: true }); + const commandPromises: Array> = []; + + for (const file of files) { + const fullPath = path.join(commandsDir, file); + commandPromises.push(createCommandFromFile(fullPath, commandsDir)); + } + + const commands = (await Promise.all(commandPromises)).filter( + (cmd): cmd is CommandModule => cmd !== null, + ); + + return commands; + } catch (_error) { + // If the directory doesn't exist or there's a reading error, do nothing. + return []; + } +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 81fc01f001d..b37291444d6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -48,6 +48,7 @@ import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; import { runExitCleanup } from '../utils/cleanup.js'; +import { loadCustomCommands } from '../commands/custom.js'; export interface CliArgs { query: string | undefined; @@ -287,6 +288,11 @@ export async function parseArguments(settings: Settings): Promise { yargsInstance.command(hooksCommand); } + const customCommands = await loadCustomCommands(); + for (const cmd of customCommands) { + yargsInstance.command(cmd); + } + yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version')