diff --git a/.repo-updates-log b/.repo-updates-log new file mode 100644 index 00000000..41a0d466 --- /dev/null +++ b/.repo-updates-log @@ -0,0 +1,13 @@ +## Repository Updates Log + +### 2025-10-29 - Gemini CLI Support PR Created + +**PR #256**: feat: Add Gemini CLI support with TOML-based slash commands +- Status: Open, awaiting review from @TabishB +- Branch: `feature/add-gemini-cli-support` (commit 1ec30f6) +- Forked to: https://github.com/snoai/OpenSpec +- PR URL: https://github.com/Fission-AI/OpenSpec/pull/256 +- Changes: Added `GeminiSlashCommandConfigurator` with TOML-based slash commands (`/openspec:proposal`, `/openspec:apply`, `/openspec:archive`) +- Testing: All 244 tests passing, build successful +- Changeset: Added minor version bump +- Closes: #248 diff --git a/README.md b/README.md index 48a8ea42..ae09c759 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) | | **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) | | **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) | +| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) | | **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` | | **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) | | **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) | @@ -115,7 +116,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`. | Tools | |-------| -| Amp • Jules • Gemini CLI • Others | +| Amp • Jules • Others | ### Install & Initialize diff --git a/src/core/config.ts b/src/core/config.ts index ba60a427..ef92099e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -25,6 +25,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' }, { name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' }, { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' }, + { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' }, { name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' }, diff --git a/src/core/configurators/slash/gemini.ts b/src/core/configurators/slash/gemini.ts new file mode 100644 index 00000000..efc4243d --- /dev/null +++ b/src/core/configurators/slash/gemini.ts @@ -0,0 +1,83 @@ +import { FileSystemUtils } from '../../../utils/file-system.js'; +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId, TemplateManager } from '../../templates/index.js'; +import { OPENSPEC_MARKERS } from '../../config.js'; + +const FILE_PATHS: Record = { + proposal: '.gemini/commands/openspec/proposal.toml', + apply: '.gemini/commands/openspec/apply.toml', + archive: '.gemini/commands/openspec/archive.toml' +}; + +const DESCRIPTIONS: Record = { + proposal: 'Scaffold a new OpenSpec change and validate strictly.', + apply: 'Implement an approved OpenSpec change and keep tasks in sync.', + archive: 'Archive a deployed OpenSpec change and update specs.' +}; + +export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'gemini'; + readonly isAvailable = true; + + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + protected getFrontmatter(_id: SlashCommandId): string | undefined { + // TOML doesn't use separate frontmatter - it's all in one structure + return undefined; + } + + // Override to generate TOML format with markers inside the prompt field + async generateAll(projectPath: string, _openspecDir: string): Promise { + const createdOrUpdated: string[] = []; + + for (const target of this.getTargets()) { + const body = this.getBody(target.id); + const filePath = FileSystemUtils.joinPath(projectPath, target.path); + + if (await FileSystemUtils.fileExists(filePath)) { + await this.updateBody(filePath, body); + } else { + const tomlContent = this.generateTOML(target.id, body); + await FileSystemUtils.writeFile(filePath, tomlContent); + } + + createdOrUpdated.push(target.path); + } + + return createdOrUpdated; + } + + private generateTOML(id: SlashCommandId, body: string): string { + const description = DESCRIPTIONS[id]; + + // TOML format with triple-quoted string for multi-line prompt + // Markers are inside the prompt value + return `description = "${description}" + +prompt = """ +${OPENSPEC_MARKERS.start} +${body} +${OPENSPEC_MARKERS.end} +""" +`; + } + + // Override updateBody to handle TOML format + protected async updateBody(filePath: string, body: string): Promise { + const content = await FileSystemUtils.readFile(filePath); + const startIndex = content.indexOf(OPENSPEC_MARKERS.start); + const endIndex = content.indexOf(OPENSPEC_MARKERS.end); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + throw new Error(`Missing OpenSpec markers in ${filePath}`); + } + + const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length); + const after = content.slice(endIndex); + const updatedContent = `${before}\n${body}\n${after}`; + + await FileSystemUtils.writeFile(filePath, updatedContent); + } +} diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index 1872b619..50989f06 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -10,6 +10,7 @@ import { CodexSlashCommandConfigurator } from './codex.js'; import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js'; import { AmazonQSlashCommandConfigurator } from './amazon-q.js'; import { FactorySlashCommandConfigurator } from './factory.js'; +import { GeminiSlashCommandConfigurator } from './gemini.js'; import { AuggieSlashCommandConfigurator } from './auggie.js'; import { ClineSlashCommandConfigurator } from './cline.js'; import { CrushSlashCommandConfigurator } from './crush.js'; @@ -31,6 +32,7 @@ export class SlashCommandRegistry { const githubCopilot = new GitHubCopilotSlashCommandConfigurator(); const amazonQ = new AmazonQSlashCommandConfigurator(); const factory = new FactorySlashCommandConfigurator(); + const gemini = new GeminiSlashCommandConfigurator(); const auggie = new AuggieSlashCommandConfigurator(); const cline = new ClineSlashCommandConfigurator(); const crush = new CrushSlashCommandConfigurator(); @@ -48,6 +50,7 @@ export class SlashCommandRegistry { this.configurators.set(githubCopilot.toolId, githubCopilot); this.configurators.set(amazonQ.toolId, amazonQ); this.configurators.set(factory.toolId, factory); + this.configurators.set(gemini.toolId, gemini); this.configurators.set(auggie.toolId, auggie); this.configurators.set(cline.toolId, cline); this.configurators.set(crush.toolId, crush); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index dad27726..011e06fc 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -305,6 +305,73 @@ describe('InitCommand', () => { expect(archiveContent).toContain('openspec list --specs'); }); + it('should create Gemini CLI TOML files when selected', async () => { + queueSelections('gemini', DONE); + + await initCommand.execute(testDir); + + const geminiProposal = path.join( + testDir, + '.gemini/commands/openspec/proposal.toml' + ); + const geminiApply = path.join( + testDir, + '.gemini/commands/openspec/apply.toml' + ); + const geminiArchive = path.join( + testDir, + '.gemini/commands/openspec/archive.toml' + ); + + expect(await fileExists(geminiProposal)).toBe(true); + expect(await fileExists(geminiApply)).toBe(true); + expect(await fileExists(geminiArchive)).toBe(true); + + const proposalContent = await fs.readFile(geminiProposal, 'utf-8'); + expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."'); + expect(proposalContent).toContain('prompt = """'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + expect(proposalContent).toContain(''); + + const applyContent = await fs.readFile(geminiApply, 'utf-8'); + expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(geminiArchive, 'utf-8'); + expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."'); + expect(archiveContent).toContain('openspec archive '); + }); + + it('should update existing Gemini CLI TOML files with refreshed content', async () => { + queueSelections('gemini', DONE); + + await initCommand.execute(testDir); + + const geminiProposal = path.join( + testDir, + '.gemini/commands/openspec/proposal.toml' + ); + + // Modify the file to simulate user customization + const originalContent = await fs.readFile(geminiProposal, 'utf-8'); + const modifiedContent = originalContent.replace( + '', + '\nCustom instruction added by user\n' + ); + await fs.writeFile(geminiProposal, modifiedContent); + + // Run init again to test update/refresh path + queueSelections('gemini', DONE); + await initCommand.execute(testDir); + + const updatedContent = await fs.readFile(geminiProposal, 'utf-8'); + expect(updatedContent).toContain(''); + expect(updatedContent).toContain('**Guardrails**'); + expect(updatedContent).toContain(''); + expect(updatedContent).not.toContain('Custom instruction added by user'); + }); + it('should create OpenCode slash command files with templates', async () => { queueSelections('opencode', DONE);