From 16f73ad24de541951604f4630ec971b8de96f7dd Mon Sep 17 00:00:00 2001 From: Storm-Chaser Date: Mon, 11 May 2026 16:06:55 +0800 Subject: [PATCH] feat: add GSD tool command adapter Add support for GSD (Pi's project management system) as a new AI tool integration. GSD uses agent definition files stored in .gsd/agents/ directory with YAML frontmatter containing name and description fields. Changes: - Add gsd.ts adapter with YAML frontmatter formatting and path generation - Register gsdAdapter in CommandAdapterRegistry and adapters index - Add unit tests covering toolId, file path generation, YAML formatting, and special character escaping in YAML values GSD agent files follow the pattern: .gsd/agents/opsx-.md --- src/core/command-generation/adapters/gsd.ts | 50 +++++++++++++++++++ src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/registry.ts | 2 + test/core/command-generation/adapters.test.ts | 33 +++++++++++- 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/core/command-generation/adapters/gsd.ts diff --git a/src/core/command-generation/adapters/gsd.ts b/src/core/command-generation/adapters/gsd.ts new file mode 100644 index 000000000..1f072ee27 --- /dev/null +++ b/src/core/command-generation/adapters/gsd.ts @@ -0,0 +1,50 @@ +/** + * GSD Command Adapter + * + * Formats commands for GSD (Pi's project management system). + * GSD agent files live in .gsd/agents/*.md with YAML frontmatter + * containing name and description for the agent definition. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Escapes a string value for safe YAML output. + * Quotes the string if it contains special YAML characters. + */ +function escapeYamlValue(value: string): string { + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +/** + * GSD adapter for agent file generation. + * File path: .gsd/agents/opsx-.md + * Frontmatter: name, description + * + * GSD uses the agent .md files as agent definitions that can be + * invoked by the GSD subagent system. Command references in the + * body use hyphen-based format (/opsx-*) for compatibility. + */ +export const gsdAdapter: ToolCommandAdapter = { + toolId: 'gsd', + + getFilePath(commandId: string): string { + return path.join('.gsd', 'agents', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d..d0f7fdc07 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -30,3 +30,4 @@ export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; export { windsurfAdapter } from './windsurf.js'; +export { gsdAdapter } from './gsd.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707..2c4c45799 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -32,6 +32,7 @@ import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; import { windsurfAdapter } from './adapters/windsurf.js'; +import { gsdAdapter } from './adapters/gsd.js'; /** * Registry for looking up tool command adapters. @@ -67,6 +68,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); CommandAdapterRegistry.register(windsurfAdapter); + CommandAdapterRegistry.register(gsdAdapter); } /** diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..208a79adb 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -24,6 +24,7 @@ import { qoderAdapter } from '../../../src/core/command-generation/adapters/qode import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; +import { gsdAdapter } from '../../../src/core/command-generation/adapters/gsd.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; describe('command-generation/adapters', () => { @@ -673,6 +674,36 @@ describe('command-generation/adapters', () => { }); }); + describe('gsdAdapter', () => { + it('should have correct toolId', () => { + expect(gsdAdapter.toolId).toBe('gsd'); + }); + + it('should generate correct file path', () => { + const filePath = gsdAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.gsd', 'agents', 'opsx-explore.md')); + }); + + it('should format file with YAML frontmatter', () => { + const output = gsdAdapter.formatFile(sampleContent); + expect(output).toContain('---'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('This is the command body.'); + }); + + it('should quote YAML values containing special characters', () => { + const contentWithSpecial: CommandContent = { + ...sampleContent, + name: 'Test: special', + description: 'Has #hash and "quotes"', + }; + const output = gsdAdapter.formatFile(contentWithSpecial); + expect(output).toContain('name: "Test: special"'); + expect(output).toContain('description: "Has #hash and \\"quotes\\""'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -698,7 +729,7 @@ describe('command-generation/adapters', () => { codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, - qwenAdapter, roocodeAdapter + qwenAdapter, roocodeAdapter, gsdAdapter ]; for (const adapter of adapters) { const filePath = adapter.getFilePath('test');