diff --git a/docs/github-cli-usage.md b/docs/github-cli-usage.md new file mode 100644 index 0000000..b8c0c66 --- /dev/null +++ b/docs/github-cli-usage.md @@ -0,0 +1,50 @@ +# GitHub CLI Usage in MyCoder + +This document explains how to properly use the GitHub CLI (`gh`) with MyCoder, especially when creating issues, PRs, or comments with multiline content. + +## Using `stdinContent` for Multiline Content + +When creating GitHub issues, PRs, or comments via the `gh` CLI tool, always use the `stdinContent` parameter for multiline content: + +```javascript +shellStart({ + command: 'gh issue create --body-stdin', + stdinContent: + 'Issue description here with **markdown** support\nThis is a new line', + description: 'Creating a new issue', +}); +``` + +## Handling Newlines + +MyCoder automatically handles newlines in two ways: + +1. **Actual newlines** in template literals: + + ```javascript + stdinContent: `Line 1 + Line 2 + Line 3`; + ``` + +2. **Escaped newlines** in regular strings: + ```javascript + stdinContent: 'Line 1\\nLine 2\\nLine 3'; + ``` + +Both approaches will result in properly formatted multiline content in GitHub. MyCoder automatically converts literal `\n` sequences to actual newlines before sending the content to the GitHub CLI. + +## Best Practices + +- Use template literals (backticks) for multiline content whenever possible, as they're more readable +- When working with dynamic strings that might contain `\n`, don't worry - MyCoder will handle the conversion automatically +- Always use `--body-stdin` (or equivalent) flags with the GitHub CLI to ensure proper formatting +- For very large content, consider using `--body-file` with a temporary file instead + +## Common Issues + +If you notice that your GitHub comments or PR descriptions still contain literal `\n` sequences: + +1. Make sure you're using the `stdinContent` parameter with `shellStart` or `shellExecute` +2. Verify that you're using the correct GitHub CLI flags (e.g., `--body-stdin`) +3. Check if your content is being processed by another function before reaching `stdinContent` that might be escaping the newlines diff --git a/packages/agent/src/tools/shell/shellExecute.test.ts b/packages/agent/src/tools/shell/shellExecute.test.ts index 6ac8fb5..38ac6e1 100644 --- a/packages/agent/src/tools/shell/shellExecute.test.ts +++ b/packages/agent/src/tools/shell/shellExecute.test.ts @@ -1,9 +1,85 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -// Skip testing for now -describe.skip('shellExecuteTool', () => { - it('should execute a shell command', async () => { +import { shellExecuteTool } from './shellExecute'; + +// Mock child_process.exec +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +// Mock util.promisify to return our mocked exec function +vi.mock('util', () => ({ + promisify: vi.fn((fn) => fn), +})); + +describe('shellExecuteTool', () => { + // Original test - skipped + it.skip('should execute a shell command', async () => { // This is a dummy test that will be skipped expect(true).toBe(true); }); + + // New test for newline conversion + it('should properly convert literal newlines in stdinContent', async () => { + // Setup + const { exec } = await import('child_process'); + const stdinWithLiteralNewlines = 'Line 1\\nLine 2\\nLine 3'; + const expectedProcessedContent = 'Line 1\nLine 2\nLine 3'; + + // Create a minimal mock context + const mockContext = { + logger: { + debug: vi.fn(), + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + workingDirectory: '/test', + headless: false, + userSession: false, + tokenTracker: { trackTokens: vi.fn() }, + githubMode: false, + provider: 'anthropic', + maxTokens: 4000, + temperature: 0, + agentTracker: { registerAgent: vi.fn() }, + shellTracker: { registerShell: vi.fn(), processStates: new Map() }, + browserTracker: { registerSession: vi.fn() }, + }; + + // Create a real Buffer but spy on the toString method + const realBuffer = Buffer.from('test'); + const bufferSpy = vi + .spyOn(Buffer, 'from') + .mockImplementationOnce((content) => { + // Store the actual content for verification + if (typeof content === 'string') { + // This is where we verify the content has been transformed + expect(content).toEqual(expectedProcessedContent); + } + return realBuffer; + }); + + // Mock exec to resolve with empty stdout/stderr + (exec as any).mockImplementationOnce((cmd, opts, callback) => { + callback(null, { stdout: '', stderr: '' }); + }); + + // Execute the tool with literal newlines in stdinContent + await shellExecuteTool.execute( + { + command: 'cat', + description: 'Testing literal newline conversion', + stdinContent: stdinWithLiteralNewlines, + }, + mockContext as any, + ); + + // Verify the Buffer.from was called + expect(bufferSpy).toHaveBeenCalled(); + + // Reset mocks + bufferSpy.mockRestore(); + }); }); diff --git a/packages/agent/src/tools/shell/shellExecute.ts b/packages/agent/src/tools/shell/shellExecute.ts index 2bdf595..0bbc043 100644 --- a/packages/agent/src/tools/shell/shellExecute.ts +++ b/packages/agent/src/tools/shell/shellExecute.ts @@ -74,6 +74,9 @@ export const shellExecuteTool: Tool = { // If stdinContent is provided, use platform-specific approach to pipe content if (stdinContent && stdinContent.length > 0) { + // Replace literal \n with actual newlines and \t with actual tabs + stdinContent = stdinContent.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + const isWindows = process.platform === 'win32'; const encodedContent = Buffer.from(stdinContent).toString('base64'); diff --git a/packages/agent/src/tools/shell/shellStart.test.ts b/packages/agent/src/tools/shell/shellStart.test.ts index aebc68a..d0bc41c 100644 --- a/packages/agent/src/tools/shell/shellStart.test.ts +++ b/packages/agent/src/tools/shell/shellStart.test.ts @@ -192,4 +192,46 @@ describe('shellStartTool', () => { 'With stdin content of length: 12', ); }); + + it('should properly convert literal newlines in stdinContent', async () => { + await import('child_process'); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + }); + + const stdinWithLiteralNewlines = 'Line 1\\nLine 2\\nLine 3'; + const expectedProcessedContent = 'Line 1\nLine 2\nLine 3'; + + // Capture the actual content being passed to Buffer.from + let capturedContent = ''; + vi.spyOn(Buffer, 'from').mockImplementationOnce((content) => { + if (typeof content === 'string') { + capturedContent = content; + } + // Call the real implementation for encoding + return Buffer.from(content); + }); + + await shellStartTool.execute( + { + command: 'cat', + description: 'Testing literal newline conversion', + timeout: 0, + stdinContent: stdinWithLiteralNewlines, + }, + mockToolContext, + ); + + // Verify that the literal newlines were converted to actual newlines + expect(capturedContent).toEqual(expectedProcessedContent); + + // Reset mocks and platform + vi.spyOn(Buffer, 'from').mockRestore(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + }); + }); }); diff --git a/packages/agent/src/tools/shell/shellStart.ts b/packages/agent/src/tools/shell/shellStart.ts index 43ffeae..b5129e4 100644 --- a/packages/agent/src/tools/shell/shellStart.ts +++ b/packages/agent/src/tools/shell/shellStart.ts @@ -117,6 +117,11 @@ export const shellStartTool: Tool = { let childProcess; if (stdinContent && stdinContent.length > 0) { + // Replace literal \n with actual newlines and \t with actual tabs + stdinContent = stdinContent + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t'); + if (isWindows) { // Windows approach using PowerShell const encodedContent = Buffer.from(stdinContent).toString('base64'); diff --git a/test_content.txt b/test_content.txt new file mode 100644 index 0000000..07353c6 --- /dev/null +++ b/test_content.txt @@ -0,0 +1,3 @@ +This is line 1. +This is line 2. +This is line 3. \ No newline at end of file