Compare commits

...
5 changed files with 108 additions and 132 deletions
+5 -61
View File
@@ -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',
@@ -15,7 +13,7 @@ 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;
@@ -23,61 +21,7 @@ export class GeminiSlashCommandConfigurator extends SlashCommandConfigurator {
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];
}
}
+12 -37
View File
@@ -5,7 +5,7 @@
*
* @implements {SlashCommandConfigurator}
*/
import { SlashCommandConfigurator } from './base.js';
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';
/**
@@ -13,35 +13,15 @@ import { SlashCommandId } from '../../templates/index.js';
* @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.'
};
/**
@@ -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;
@@ -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
View File
@@ -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);
}
}
+8 -13
View File
@@ -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 () => {
@@ -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);
@@ -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>');
});
+17 -21
View File
@@ -152,19 +152,17 @@ 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');
@@ -172,8 +170,8 @@ Old body
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');
@@ -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();
@@ -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);
@@ -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);