Skip to content

Commit cdd3df0

Browse files
committed
fix: harden Gemini and Qwen TOML parsing (v1.8.3)
1 parent 7123744 commit cdd3df0

7 files changed

Lines changed: 82 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.8.3] - 2025-11-15
9+
10+
### 🐛 Fixes
11+
- Hardened Gemini CLI and Qwen Code CLI TOML parsing so only the inner prompt body is injected, preventing duplicated headers and keeping custom commands discoverable.
12+
13+
### 🧪 Testing
14+
- `npm run lint`
15+
- `npx tsc --noEmit`
16+
- `NODE_OPTIONS="--localstorage-file=.jest-localstorage" npm test`
17+
- `npm run build:prod`
18+
819
## [1.8.2] - 2025-11-15
920

1021
### ✨ Features

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clavix",
3-
"version": "1.8.2",
3+
"version": "1.8.3",
44
"description": "AI prompt improvement and PRD generation CLI tool for developers",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/cli/commands/init.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ClavixConfig, DEFAULT_CONFIG } from '../../types/config';
1212
import { CommandTemplate, AgentAdapter } from '../../types/agent';
1313
import { GeminiAdapter } from '../../core/adapters/gemini-adapter';
1414
import { QwenAdapter } from '../../core/adapters/qwen-adapter';
15+
import { parseTomlSlashCommand } from '../../utils/toml-templates';
1516

1617
export default class Init extends Command {
1718
static description = 'Initialize Clavix in the current project';
@@ -382,7 +383,7 @@ See documentation for template format details.
382383
const name = file.slice(0, -extension.length);
383384

384385
if (extension === '.toml') {
385-
const parsed = this.parseTomlTemplate(content, name, adapter.name);
386+
const parsed = parseTomlSlashCommand(content, name, adapter.name);
386387
templates.push({
387388
name,
388389
content: parsed.prompt,
@@ -463,19 +464,6 @@ See documentation for template format details.
463464
return '';
464465
}
465466

466-
private parseTomlTemplate(content: string, templateName: string, providerName: string): { description: string; prompt: string } {
467-
const descriptionMatch = content.match(/^\s*description\s*=\s*(['"])(.*?)\1/m);
468-
const promptMatch = content.match(/^\s*prompt\s*=\s*"""([\s\S]*?)"""/m);
469-
470-
if (!promptMatch) {
471-
throw new Error(`Template ${templateName}.toml for ${providerName} is missing a prompt = """ ... """ block.`);
472-
}
473-
474-
const description = descriptionMatch ? descriptionMatch[2] : '';
475-
const prompt = promptMatch[1];
476-
477-
return { description, prompt };
478-
}
479467

480468
private extractClavixBlock(content: string): string {
481469
const match = content.match(/<!-- CLAVIX:START -->([\s\S]*?)<!-- CLAVIX:END -->/);

src/cli/commands/update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export default class Update extends Command {
161161
}
162162

163163
private async updateCommands(adapter: AgentAdapter, force: boolean): Promise<number> {
164-
this.log(chalk.cyan('\n🔧 Updating slash commands...'));
164+
this.log(chalk.cyan(`\n🔧 Updating slash commands for ${adapter.displayName}...`));
165165

166166
const commandsDir = adapter.getCommandPath();
167167
const commandsPath = path.join(process.cwd(), commandsDir);

src/utils/toml-templates.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface ParsedTomlTemplate {
2+
description: string;
3+
prompt: string;
4+
}
5+
6+
/**
7+
* Parse TOML-based slash command templates (Gemini/Qwen) and extract metadata.
8+
* Ensures the resulting prompt body does not include duplicated frontmatter.
9+
*/
10+
export function parseTomlSlashCommand(
11+
content: string,
12+
templateName: string,
13+
providerName: string,
14+
): ParsedTomlTemplate {
15+
let normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
16+
if (normalized.charCodeAt(0) === 0xfeff) {
17+
normalized = normalized.slice(1);
18+
}
19+
20+
const descriptionMatch = normalized.match(/^\s*description\s*=\s*(['"])(.*?)\1\s*$/m);
21+
const promptHeaderMatch = normalized.match(/^\s*prompt\s*=\s*"""/m);
22+
23+
if (!promptHeaderMatch || promptHeaderMatch.index === undefined) {
24+
throw new Error(`Template ${templateName}.toml for ${providerName} is missing a prompt = """ ... """ block.`);
25+
}
26+
27+
const bodyStart = promptHeaderMatch.index + promptHeaderMatch[0].length;
28+
const bodyRemainder = normalized.slice(bodyStart);
29+
const closingIndex = bodyRemainder.indexOf('"""');
30+
31+
if (closingIndex === -1) {
32+
throw new Error(`Template ${templateName}.toml for ${providerName} does not terminate its prompt = """ ... """ block.`);
33+
}
34+
35+
let promptBody = bodyRemainder.slice(0, closingIndex);
36+
const promptLines = promptBody.split('\n');
37+
38+
while (promptLines.length > 0 && /^\s*(description\s*=|prompt\s*=)/.test(promptLines[0])) {
39+
promptLines.shift();
40+
}
41+
42+
promptBody = promptLines.join('\n').replace(/^\n+/, '').replace(/[\s]+$/, '');
43+
44+
return {
45+
description: descriptionMatch ? descriptionMatch[2] : '',
46+
prompt: promptBody,
47+
};
48+
}

tests/cli/init.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as path from 'path';
77
import { AgentManager } from '../../src/core/agent-manager';
88
import { FileSystem } from '../../src/utils/file-system';
99
import { DEFAULT_CONFIG } from '../../src/types/config';
10+
import { parseTomlSlashCommand } from '../../src/utils/toml-templates';
1011

1112
describe('Init command', () => {
1213
const testDir = path.join(__dirname, '../fixtures/test-init');
@@ -377,6 +378,22 @@ describe('Init command', () => {
377378
});
378379
});
379380

381+
describe('TOML template parsing', () => {
382+
it('extracts description and prompt body without duplicating headers', async () => {
383+
const templatePath = path.join(__dirname, '../../src/templates/slash-commands/gemini/archive.toml');
384+
const templateContent = await fs.readFile(templatePath, 'utf8');
385+
386+
const parsed = parseTomlSlashCommand(templateContent, 'archive', 'gemini');
387+
388+
expect(parsed.description).toBe('Archive completed PRD projects');
389+
expect(parsed.prompt.startsWith('# Clavix Archive')).toBe(true);
390+
expect(parsed.prompt).not.toMatch(/^description\s*=/m);
391+
expect(parsed.prompt).not.toMatch(/^prompt\s*=/m);
392+
const occurrences = (parsed.prompt.match(/prompt\s*=\s*"""/g) ?? []).length;
393+
expect(occurrences).toBe(0);
394+
});
395+
});
396+
380397
describe('edge cases', () => {
381398
it('should handle empty providers array validation', () => {
382399
const providers: string[] = [];

0 commit comments

Comments
 (0)