Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .repo-updates-log
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Repository Updates Log
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to track this, this can be dropped


### 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
83 changes: 83 additions & 0 deletions src/core/configurators/slash/gemini.ts
Original file line number Diff line number Diff line change
@@ -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<SlashCommandId, string> = {
proposal: '.gemini/commands/openspec/proposal.toml',
apply: '.gemini/commands/openspec/apply.toml',
archive: '.gemini/commands/openspec/archive.toml'
};

const DESCRIPTIONS: Record<SlashCommandId, string> = {
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<string[]> {
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<void> {
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);
}
}
3 changes: 3 additions & 0 deletions src/core/configurators/slash/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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);
Expand Down
67 changes: 67 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');

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 <id>');
});

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(
'<!-- OPENSPEC:START -->',
'<!-- OPENSPEC:START -->\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('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('**Guardrails**');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).not.toContain('Custom instruction added by user');
});

it('should create OpenCode slash command files with templates', async () => {
queueSelections('opencode', DONE);

Expand Down
Loading