Skip to content

Commit 5342a0f

Browse files
committed
feat: add stdinContent parameter to shell commands
This commit adds a new `stdinContent` parameter to the shellStart and shellExecute functions, which allows passing content directly to shell commands via stdin. This is particularly useful for GitHub CLI commands that accept stdin input. Key changes: - Add stdinContent parameter to shellStart and shellExecute - Implement cross-platform approach using base64 encoding - Update GitHub mode instructions to use stdinContent instead of temporary files - Add tests for the new functionality - Add example test files demonstrating usage Closes #301
1 parent 77ae98a commit 5342a0f

File tree

7 files changed

+555
-182
lines changed

7 files changed

+555
-182
lines changed

packages/agent/src/core/toolAgent/config.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,10 @@ export function getDefaultSystemPrompt(toolContext: ToolContext): string {
126126
'',
127127
'You should use the Github CLI tool, gh, and the git cli tool, git, that you can access via shell commands.',
128128
'',
129-
'When creating GitHub issues, PRs, or comments, via the gh cli tool, use temporary markdown files for the content instead of inline text:',
130-
'- Create a temporary markdown file with the content you want to include',
131-
'- Use the file with GitHub CLI commands (e.g., `gh issue create --body-file temp.md`)',
132-
'- Clean up the temporary file when done',
133-
'- This approach preserves formatting, newlines, and special characters correctly',
129+
'When creating GitHub issues, PRs, or comments via the gh cli tool, use the shellStart or shellExecute stdinContent parameter for multiline content:',
130+
'- Use the stdinContent parameter to pass the content directly to the command',
131+
'- For example: `shellStart({ command: "gh issue create --body-stdin", stdinContent: "Issue description here with **markdown** support", description: "Creating a new issue" })`',
132+
'- This approach preserves formatting, newlines, and special characters correctly without requiring temporary files',
134133
].join('\n')
135134
: '';
136135

Original file line numberDiff line numberDiff line change
@@ -1,26 +1,133 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
import { ToolContext } from '../../core/types.js';
4-
import { getMockToolContext } from '../getTools.test.js';
3+
import { shellExecuteTool } from './shellExecute';
54

6-
import { shellExecuteTool } from './shellExecute.js';
5+
// Mock child_process.exec
6+
vi.mock('child_process', () => {
7+
return {
8+
exec: vi.fn(),
9+
};
10+
});
11+
12+
// Mock util.promisify to return our mocked exec
13+
vi.mock('util', () => {
14+
return {
15+
promisify: vi.fn((_fn) => {
16+
return async () => {
17+
return { stdout: 'mocked stdout', stderr: 'mocked stderr' };
18+
};
19+
}),
20+
};
21+
});
22+
23+
describe('shellExecuteTool', () => {
24+
const mockLogger = {
25+
log: vi.fn(),
26+
debug: vi.fn(),
27+
error: vi.fn(),
28+
warn: vi.fn(),
29+
info: vi.fn(),
30+
};
731

8-
const toolContext: ToolContext = getMockToolContext();
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
afterEach(() => {
37+
vi.resetAllMocks();
38+
});
939

10-
describe('shellExecute', () => {
11-
it('should execute shell commands', async () => {
12-
const { stdout } = await shellExecuteTool.execute(
13-
{ command: "echo 'test'", description: 'test' },
14-
toolContext,
40+
it('should execute a shell command without stdinContent', async () => {
41+
const result = await shellExecuteTool.execute(
42+
{
43+
command: 'echo "test"',
44+
description: 'Testing command',
45+
},
46+
{
47+
logger: mockLogger as any,
48+
},
1549
);
16-
expect(stdout).toContain('test');
50+
51+
expect(mockLogger.debug).toHaveBeenCalledWith(
52+
'Executing shell command with 30000ms timeout: echo "test"',
53+
);
54+
expect(result).toEqual({
55+
stdout: 'mocked stdout',
56+
stderr: 'mocked stderr',
57+
code: 0,
58+
error: '',
59+
command: 'echo "test"',
60+
});
1761
});
1862

19-
it('should handle command errors', async () => {
20-
const { error } = await shellExecuteTool.execute(
21-
{ command: 'nonexistentcommand', description: 'test' },
22-
toolContext,
63+
it('should execute a shell command with stdinContent', async () => {
64+
const result = await shellExecuteTool.execute(
65+
{
66+
command: 'cat',
67+
description: 'Testing with stdin content',
68+
stdinContent: 'test content',
69+
},
70+
{
71+
logger: mockLogger as any,
72+
},
73+
);
74+
75+
expect(mockLogger.debug).toHaveBeenCalledWith(
76+
'Executing shell command with 30000ms timeout: cat',
77+
);
78+
expect(mockLogger.debug).toHaveBeenCalledWith(
79+
'With stdin content of length: 12',
2380
);
24-
expect(error).toContain('Command failed:');
81+
expect(result).toEqual({
82+
stdout: 'mocked stdout',
83+
stderr: 'mocked stderr',
84+
code: 0,
85+
error: '',
86+
command: 'cat',
87+
});
2588
});
26-
});
89+
90+
it('should include stdinContent in log parameters', () => {
91+
shellExecuteTool.logParameters(
92+
{
93+
command: 'cat',
94+
description: 'Testing log parameters',
95+
stdinContent: 'test content',
96+
},
97+
{
98+
logger: mockLogger as any,
99+
},
100+
);
101+
102+
expect(mockLogger.log).toHaveBeenCalledWith(
103+
'Running "cat", Testing log parameters (with stdin content)',
104+
);
105+
});
106+
107+
it('should handle errors during execution', async () => {
108+
// Override the promisify mock to throw an error
109+
vi.mocked(vi.importActual('util') as any).promisify.mockImplementationOnce(
110+
() => {
111+
return async () => {
112+
throw new Error('Command failed');
113+
};
114+
},
115+
);
116+
117+
const result = await shellExecuteTool.execute(
118+
{
119+
command: 'invalid-command',
120+
description: 'Testing error handling',
121+
},
122+
{
123+
logger: mockLogger as any,
124+
},
125+
);
126+
127+
expect(mockLogger.debug).toHaveBeenCalledWith(
128+
'Executing shell command with 30000ms timeout: invalid-command',
129+
);
130+
expect(result.error).toContain('Command failed');
131+
expect(result.code).toBe(-1);
132+
});
133+
});

packages/agent/src/tools/shell/shellExecute.ts

+40-6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const parameterSchema = z.object({
2020
.number()
2121
.optional()
2222
.describe('Timeout in milliseconds (optional, default 30000)'),
23+
stdinContent: z
24+
.string()
25+
.optional()
26+
.describe(
27+
'Content to pipe into the shell command as stdin (useful for passing multiline content to commands)',
28+
),
2329
});
2430

2531
const returnSchema = z
@@ -53,18 +59,46 @@ export const shellExecuteTool: Tool<Parameters, ReturnType> = {
5359
returnsJsonSchema: zodToJsonSchema(returnSchema),
5460

5561
execute: async (
56-
{ command, timeout = 30000 },
62+
{ command, timeout = 30000, stdinContent },
5763
{ logger },
5864
): Promise<ReturnType> => {
5965
logger.debug(
6066
`Executing shell command with ${timeout}ms timeout: ${command}`,
6167
);
68+
if (stdinContent) {
69+
logger.debug(`With stdin content of length: ${stdinContent.length}`);
70+
}
6271

6372
try {
64-
const { stdout, stderr } = await execAsync(command, {
65-
timeout,
66-
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
67-
});
73+
let stdout, stderr;
74+
75+
// If stdinContent is provided, use platform-specific approach to pipe content
76+
if (stdinContent && stdinContent.length > 0) {
77+
const isWindows = process.platform === 'win32';
78+
const encodedContent = Buffer.from(stdinContent).toString('base64');
79+
80+
if (isWindows) {
81+
// Windows approach using PowerShell
82+
const powershellCommand = `[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedContent}')) | ${command}`;
83+
({ stdout, stderr } = await execAsync(`powershell -Command "${powershellCommand}"`, {
84+
timeout,
85+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
86+
}));
87+
} else {
88+
// POSIX approach (Linux/macOS)
89+
const bashCommand = `echo "${encodedContent}" | base64 -d | ${command}`;
90+
({ stdout, stderr } = await execAsync(bashCommand, {
91+
timeout,
92+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
93+
}));
94+
}
95+
} else {
96+
// No stdin content, use normal approach
97+
({ stdout, stderr } = await execAsync(command, {
98+
timeout,
99+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
100+
}));
101+
}
68102

69103
logger.debug('Command executed successfully');
70104
logger.debug(`stdout: ${stdout.trim()}`);
@@ -109,7 +143,7 @@ export const shellExecuteTool: Tool<Parameters, ReturnType> = {
109143
}
110144
},
111145
logParameters: (input, { logger }) => {
112-
logger.log(`Running "${input.command}", ${input.description}`);
146+
logger.log(`Running "${input.command}", ${input.description}${input.stdinContent ? ' (with stdin content)' : ''}`);
113147
},
114148
logReturns: () => {},
115149
};

0 commit comments

Comments
 (0)