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
66 changes: 5 additions & 61 deletions src/core/configurators/slash/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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';
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';

const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.gemini/commands/openspec/proposal.toml',
Expand All @@ -15,69 +13,15 @@ const DESCRIPTIONS: Record<SlashCommandId, string> = {
archive: 'Archive a deployed OpenSpec change and update specs.'
};

export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
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);
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
49 changes: 12 additions & 37 deletions src/core/configurators/slash/qwen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,23 @@
*
* @implements {SlashCommandConfigurator}
*/
import { SlashCommandConfigurator } from './base.js';
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';

/**
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
* @type {Record<SlashCommandId, string>}
*/
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.qwen/commands/openspec-proposal.md',
apply: '.qwen/commands/openspec-apply.md',
archive: '.qwen/commands/openspec-archive.md'
proposal: '.qwen/commands/openspec-proposal.toml',
apply: '.qwen/commands/openspec-apply.toml',
archive: '.qwen/commands/openspec-archive.toml'
};

/**
* YAML frontmatter definitions for Qwen command files.
* These provide metadata for each slash command to ensure proper recognition by Qwen Code.
* @type {Record<SlashCommandId, string>}
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: /openspec-proposal
id: openspec-proposal
category: OpenSpec
description: Scaffold a new OpenSpec change and validate strictly.
---`,
apply: `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
archive: `---
name: /openspec-archive
id: openspec-archive
category: OpenSpec
description: Archive a deployed OpenSpec change and update specs.
---`
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.'
};

/**
Expand All @@ -53,10 +33,10 @@ description: Archive a deployed OpenSpec change and update specs.
* - /openspec-apply: Apply an approved OpenSpec change
* - /openspec-archive: Archive a deployed OpenSpec change
*/
export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
/** Unique identifier for the Qwen tool */
readonly toolId = 'qwen';

/** Availability status for the Qwen tool */
readonly isAvailable = true;

Expand All @@ -69,12 +49,7 @@ export class QwenSlashCommandConfigurator extends SlashCommandConfigurator {
return FILE_PATHS[id];
}

/**
* Returns the YAML frontmatter for a given slash command ID.
* @param {SlashCommandId} id - The slash command identifier
* @returns {string} The YAML frontmatter string
*/
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
66 changes: 66 additions & 0 deletions src/core/configurators/slash/toml-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { FileSystemUtils } from '../../../utils/file-system.js';
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';

export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
protected getFrontmatter(_id: SlashCommandId): string | undefined {
// TOML doesn't use separate frontmatter - it's all in one structure
return undefined;
}

protected abstract getDescription(id: SlashCommandId): string;

// 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 = this.getDescription(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);
}
}
21 changes: 8 additions & 13 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('InitCommand', () => {
process.env.CODEX_HOME = path.join(testDir, '.codex');

// Mock console.log to suppress output during tests
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => { });
});

afterEach(async () => {
Expand Down Expand Up @@ -424,15 +424,15 @@ describe('InitCommand', () => {
const qwenConfigPath = path.join(testDir, 'QWEN.md');
const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
'.qwen/commands/openspec-proposal.toml'
);
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
'.qwen/commands/openspec-archive.toml'
);

expect(await fileExists(qwenConfigPath)).toBe(true);
Expand All @@ -446,21 +446,16 @@ describe('InitCommand', () => {
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');

const proposalContent = await fs.readFile(proposalPath, 'utf-8');
expect(proposalContent).toContain('name: /openspec-proposal');
expect(proposalContent).toContain('category: OpenSpec');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('prompt = """');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');

const applyContent = await fs.readFile(applyPath, 'utf-8');
expect(applyContent).toContain('name: /openspec-apply');
expect(applyContent).toContain('category: OpenSpec');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
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(archivePath, 'utf-8');
expect(archiveContent).toContain('name: /openspec-archive');
expect(archiveContent).toContain('category: OpenSpec');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('openspec archive <id>');
});

Expand Down
38 changes: 17 additions & 21 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,28 +152,26 @@ Old slash content
it('should refresh existing Qwen slash command files', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);
await fs.mkdir(path.dirname(applyPath), { recursive: true });
const initialContent = `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
const initialContent = `description = "Implement an approved OpenSpec change and keep tasks in sync."

prompt = """
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
<!-- OPENSPEC:END -->
"""
`;
await fs.writeFile(applyPath, initialContent);

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

const updated = await fs.readFile(applyPath, 'utf-8');
expect(updated).toContain('name: /openspec-apply');
expect(updated).toContain('category: OpenSpec');
expect(updated).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
expect(updated).toContain('prompt = """');
expect(updated).toContain('<!-- OPENSPEC:START -->');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
Expand All @@ -184,7 +182,7 @@ Old body
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .qwen/commands/openspec-apply.md'
'Updated slash commands: .qwen/commands/openspec-apply.toml'
);

consoleSpy.mockRestore();
Expand All @@ -193,22 +191,20 @@ Old body
it('should not create missing Qwen slash command files on update', async () => {
const applyPath = path.join(
testDir,
'.qwen/commands/openspec-apply.md'
'.qwen/commands/openspec-apply.toml'
);

await fs.mkdir(path.dirname(applyPath), { recursive: true });
await fs.writeFile(
applyPath,
`---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
`description = "Old description"

prompt = """
<!-- OPENSPEC:START -->
Old content
<!-- OPENSPEC:END -->`
<!-- OPENSPEC:END -->
"""
`
);

await updateCommand.execute(testDir);
Expand All @@ -219,11 +215,11 @@ Old content

const proposalPath = path.join(
testDir,
'.qwen/commands/openspec-proposal.md'
'.qwen/commands/openspec-proposal.toml'
);
const archivePath = path.join(
testDir,
'.qwen/commands/openspec-archive.md'
'.qwen/commands/openspec-archive.toml'
);

await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);
Expand Down
Loading