Skip to content

Commit 9b6a763

Browse files
LarHopeTabishB
andauthored
feat: Add Gemini CLI support with TOML-based slash commands (#256)
* feat: add Gemini CLI support with TOML-based slash commands - Add GeminiSlashCommandConfigurator for .gemini/commands/openspec/ - Register Gemini CLI in AI_TOOLS config and slash command registry - Generate TOML files with description and prompt fields - Add comprehensive test coverage for Gemini CLI integration - Update README to list Gemini CLI under Native Slash Commands - Remove Gemini CLI from AGENTS.md compatible list (now native) Implements GitHub issue #248 * [add] * feat: address PR feedback - remove changeset and add update test - Remove .changeset/add-gemini-cli-support.md as requested by maintainer - Add test for Gemini CLI TOML update/refresh path - Test verifies that existing TOML files are properly updated when running init again Addresses feedback from TabishB in PR #256 --------- Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
1 parent d32e50f commit 9b6a763

File tree

6 files changed

+169
-1
lines changed

6 files changed

+169
-1
lines changed

.repo-updates-log

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Repository Updates Log
2+
3+
### 2025-10-29 - Gemini CLI Support PR Created
4+
5+
**PR #256**: feat: Add Gemini CLI support with TOML-based slash commands
6+
- Status: Open, awaiting review from @TabishB
7+
- Branch: `feature/add-gemini-cli-support` (commit 1ec30f6)
8+
- Forked to: https://github.com/snoai/OpenSpec
9+
- PR URL: https://github.com/Fission-AI/OpenSpec/pull/256
10+
- Changes: Added `GeminiSlashCommandConfigurator` with TOML-based slash commands (`/openspec:proposal`, `/openspec:apply`, `/openspec:archive`)
11+
- Testing: All 244 tests passing, build successful
12+
- Changeset: Added minor version bump
13+
- Closes: #248

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
9797
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
9898
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
9999
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
100+
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
100101
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
101102
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
102103
| **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`.
115116

116117
| Tools |
117118
|-------|
118-
| Amp • Jules • Gemini CLI • Others |
119+
| Amp • Jules • Others |
119120

120121
### Install & Initialize
121122

src/core/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const AI_TOOLS: AIToolOption[] = [
2525
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
2626
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
2727
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
28+
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
2829
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
2930
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
3031
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { FileSystemUtils } from '../../../utils/file-system.js';
2+
import { SlashCommandConfigurator } from './base.js';
3+
import { SlashCommandId, TemplateManager } from '../../templates/index.js';
4+
import { OPENSPEC_MARKERS } from '../../config.js';
5+
6+
const FILE_PATHS: Record<SlashCommandId, string> = {
7+
proposal: '.gemini/commands/openspec/proposal.toml',
8+
apply: '.gemini/commands/openspec/apply.toml',
9+
archive: '.gemini/commands/openspec/archive.toml'
10+
};
11+
12+
const DESCRIPTIONS: Record<SlashCommandId, string> = {
13+
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
14+
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
15+
archive: 'Archive a deployed OpenSpec change and update specs.'
16+
};
17+
18+
export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
19+
readonly toolId = 'gemini';
20+
readonly isAvailable = true;
21+
22+
protected getRelativePath(id: SlashCommandId): string {
23+
return FILE_PATHS[id];
24+
}
25+
26+
protected getFrontmatter(_id: SlashCommandId): string | undefined {
27+
// TOML doesn't use separate frontmatter - it's all in one structure
28+
return undefined;
29+
}
30+
31+
// Override to generate TOML format with markers inside the prompt field
32+
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
33+
const createdOrUpdated: string[] = [];
34+
35+
for (const target of this.getTargets()) {
36+
const body = this.getBody(target.id);
37+
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
38+
39+
if (await FileSystemUtils.fileExists(filePath)) {
40+
await this.updateBody(filePath, body);
41+
} else {
42+
const tomlContent = this.generateTOML(target.id, body);
43+
await FileSystemUtils.writeFile(filePath, tomlContent);
44+
}
45+
46+
createdOrUpdated.push(target.path);
47+
}
48+
49+
return createdOrUpdated;
50+
}
51+
52+
private generateTOML(id: SlashCommandId, body: string): string {
53+
const description = DESCRIPTIONS[id];
54+
55+
// TOML format with triple-quoted string for multi-line prompt
56+
// Markers are inside the prompt value
57+
return `description = "${description}"
58+
59+
prompt = """
60+
${OPENSPEC_MARKERS.start}
61+
${body}
62+
${OPENSPEC_MARKERS.end}
63+
"""
64+
`;
65+
}
66+
67+
// Override updateBody to handle TOML format
68+
protected async updateBody(filePath: string, body: string): Promise<void> {
69+
const content = await FileSystemUtils.readFile(filePath);
70+
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
71+
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
72+
73+
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
74+
throw new Error(`Missing OpenSpec markers in ${filePath}`);
75+
}
76+
77+
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
78+
const after = content.slice(endIndex);
79+
const updatedContent = `${before}\n${body}\n${after}`;
80+
81+
await FileSystemUtils.writeFile(filePath, updatedContent);
82+
}
83+
}

src/core/configurators/slash/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CodexSlashCommandConfigurator } from './codex.js';
1010
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
1111
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
1212
import { FactorySlashCommandConfigurator } from './factory.js';
13+
import { GeminiSlashCommandConfigurator } from './gemini.js';
1314
import { AuggieSlashCommandConfigurator } from './auggie.js';
1415
import { ClineSlashCommandConfigurator } from './cline.js';
1516
import { CrushSlashCommandConfigurator } from './crush.js';
@@ -31,6 +32,7 @@ export class SlashCommandRegistry {
3132
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
3233
const amazonQ = new AmazonQSlashCommandConfigurator();
3334
const factory = new FactorySlashCommandConfigurator();
35+
const gemini = new GeminiSlashCommandConfigurator();
3436
const auggie = new AuggieSlashCommandConfigurator();
3537
const cline = new ClineSlashCommandConfigurator();
3638
const crush = new CrushSlashCommandConfigurator();
@@ -48,6 +50,7 @@ export class SlashCommandRegistry {
4850
this.configurators.set(githubCopilot.toolId, githubCopilot);
4951
this.configurators.set(amazonQ.toolId, amazonQ);
5052
this.configurators.set(factory.toolId, factory);
53+
this.configurators.set(gemini.toolId, gemini);
5154
this.configurators.set(auggie.toolId, auggie);
5255
this.configurators.set(cline.toolId, cline);
5356
this.configurators.set(crush.toolId, crush);

test/core/init.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,73 @@ describe('InitCommand', () => {
305305
expect(archiveContent).toContain('openspec list --specs');
306306
});
307307

308+
it('should create Gemini CLI TOML files when selected', async () => {
309+
queueSelections('gemini', DONE);
310+
311+
await initCommand.execute(testDir);
312+
313+
const geminiProposal = path.join(
314+
testDir,
315+
'.gemini/commands/openspec/proposal.toml'
316+
);
317+
const geminiApply = path.join(
318+
testDir,
319+
'.gemini/commands/openspec/apply.toml'
320+
);
321+
const geminiArchive = path.join(
322+
testDir,
323+
'.gemini/commands/openspec/archive.toml'
324+
);
325+
326+
expect(await fileExists(geminiProposal)).toBe(true);
327+
expect(await fileExists(geminiApply)).toBe(true);
328+
expect(await fileExists(geminiArchive)).toBe(true);
329+
330+
const proposalContent = await fs.readFile(geminiProposal, 'utf-8');
331+
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
332+
expect(proposalContent).toContain('prompt = """');
333+
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
334+
expect(proposalContent).toContain('**Guardrails**');
335+
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
336+
337+
const applyContent = await fs.readFile(geminiApply, 'utf-8');
338+
expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
339+
expect(applyContent).toContain('Work through tasks sequentially');
340+
341+
const archiveContent = await fs.readFile(geminiArchive, 'utf-8');
342+
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
343+
expect(archiveContent).toContain('openspec archive <id>');
344+
});
345+
346+
it('should update existing Gemini CLI TOML files with refreshed content', async () => {
347+
queueSelections('gemini', DONE);
348+
349+
await initCommand.execute(testDir);
350+
351+
const geminiProposal = path.join(
352+
testDir,
353+
'.gemini/commands/openspec/proposal.toml'
354+
);
355+
356+
// Modify the file to simulate user customization
357+
const originalContent = await fs.readFile(geminiProposal, 'utf-8');
358+
const modifiedContent = originalContent.replace(
359+
'<!-- OPENSPEC:START -->',
360+
'<!-- OPENSPEC:START -->\nCustom instruction added by user\n'
361+
);
362+
await fs.writeFile(geminiProposal, modifiedContent);
363+
364+
// Run init again to test update/refresh path
365+
queueSelections('gemini', DONE);
366+
await initCommand.execute(testDir);
367+
368+
const updatedContent = await fs.readFile(geminiProposal, 'utf-8');
369+
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
370+
expect(updatedContent).toContain('**Guardrails**');
371+
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
372+
expect(updatedContent).not.toContain('Custom instruction added by user');
373+
});
374+
308375
it('should create OpenCode slash command files with templates', async () => {
309376
queueSelections('opencode', DONE);
310377

0 commit comments

Comments
 (0)