mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-16 05:40:40 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
070b8c23bd | ||
|
|
5cac09dc3c | ||
|
|
7d5d0ea2a2 |
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user