Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gemini/commands/test-cmd.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
description = "A test command"
prompt = "What is your favorite color?"
7 changes: 0 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/commands/custom.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<CommandModule | null> {
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<CommandModule[]> {
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<Promise<CommandModule | null>> = [];

for (const file of files) {
const fullPath = path.join(commandsDir, file);
commandPromises.push(createCommandFromFile(fullPath, commandsDir));
}
Comment thread
vs-kurkin marked this conversation as resolved.

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 [];
}
}
6 changes: 6 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -287,6 +288,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
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')
Expand Down