Skip to content
Open
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
50 changes: 50 additions & 0 deletions src/core/command-generation/adapters/gsd.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +16 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Carriage return detection vs. escaping mismatch.

The regex on line 17 correctly detects \r as a character requiring quoting, but the escape logic on line 19 only handles \, ", and \n. If a value contains a carriage return, it will be quoted but the \r won't be escaped, potentially producing invalid YAML.

🛡️ Proposed fix to escape carriage returns
 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');
+    const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
     return `"${escaped}"`;
   }
   return value;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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').replace(/\r/g, '\\r');
return `"${escaped}"`;
}
return value;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/command-generation/adapters/gsd.ts` around lines 16 - 23, The
escapeYamlValue function currently quotes strings that contain carriage returns
but the replacement sequence only escapes backslashes, double quotes, and
newlines; update escapeYamlValue to also escape carriage returns by adding a
replacement for '\r' (e.g., replace(/\r/g, '\\r')) alongside the existing
.replace calls so any detected '\r' is properly escaped before wrapping in
quotes.


/**
* GSD adapter for agent file generation.
* File path: .gsd/agents/opsx-<id>.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}
`;
},
};
1 change: 1 addition & 0 deletions src/core/command-generation/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,6 +68,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
CommandAdapterRegistry.register(gsdAdapter);
}

/**
Expand Down
33 changes: 32 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down