How to test CLI tools¶
Run the selected executable directly with an argument array. Use
create_command_tool for any CLI: the command under test, its native help,
setup, discovery, and supporting commands. No PowerShell, cmd, Bash, or MCP
wrapper is required. Arguments are not shell text: spaces, quotes, empty
arguments, paths, and shell operators are passed literally.
Preferred pattern: direct process tool¶
This example requires Git on PATH. The fixture creates a disposable repository
with a direct process call. Copilot gets only a direct Git tool; the test checks
the actual index and file rather than trusting its final reply.
from __future__ import annotations
import shutil
import subprocess
from pytest_copilot.copilot import CopilotTask, create_command_tool
async def test_git_add(copilot_run, tmp_path):
git = shutil.which("git")
assert git is not None, "Install Git before running this test"
subprocess.run([git, "init", "-q"], cwd=tmp_path, check=True)
(tmp_path / "note.txt").write_text("hello\n", encoding="utf-8")
tool = create_command_tool(git, name="git", cwd=tmp_path, timeout_s=30)
agent = CopilotTask(
name="git-stage-file",
model="gpt-5.6-sol",
client_mode="empty",
instructions=(
"Use the git tool directly for all Git commands, including help "
"and discovery. Supply each argument separately, without shell "
"quoting. Use Git's native help when needed."
),
working_directory=str(tmp_path),
allowed_tools=["git"],
extra_config={"tools": [tool]},
timeout_s=120,
)
result = await copilot_run(
agent, "Stage note.txt with git add. Do not edit the file or create a commit."
)
assert result.success, result.error
staged = subprocess.run(
[git, "diff", "--cached", "--name-only"],
cwd=tmp_path,
check=True,
capture_output=True,
text=True,
)
assert staged.stdout.splitlines() == ["note.txt"]
content = subprocess.run(
[git, "show", ":note.txt"],
cwd=tmp_path,
check=True,
capture_output=True,
text=True,
)
assert content.stdout == "hello\n"
assert (tmp_path / "note.txt").read_text(encoding="utf-8") == "hello\n"
head = subprocess.run(
[git, "rev-parse", "--verify", "HEAD"],
cwd=tmp_path,
capture_output=True,
text=True,
)
assert head.returncode != 0, "The task must not create a commit"
Attach additional executables as separate named tools in extra_config["tools"]
and allowed_tools when the task needs them. Use the same direct-process rule
for supporting tools; do not silently reintroduce a shell for setup or discovery.
Keep each product's native command names and help, rather than inventing a
framework-specific command language.
Input, output, and process ownership¶
create_command_tool(executable, *, name="run_command", cwd=None, env=None,
timeout_s=30.0, cleanup_timeout_s=5.0, terminate_descendants=True) returns a
Copilot SDK Tool.
- The caller selects the executable, directory, environment, and time limits.
The model supplies
{"args": ["--help"]}and optional"stdin": "text". Standard input is UTF-8; absent input is closed, not an interactive terminal. cwddefaults to the caller's directory when the tool is created.envoverrides the environment inherited at each invocation; the tool copies the supplied overrides at creation. Per-case environment values set later remain inherited unless explicitly overridden. Use an absolute executable path when selection must not depend onPATH.- The JSON result has
stdout,stderr,exit_code,error, anderror_kind. Text is decoded as UTF-8, preserving native line endings. Only streams with invalid UTF-8 also includestdout_base64orstderr_base64to preserve the exact bytes; their text uses replacement characters. Normal help and other valid text are not duplicated as base64. Output is not truncated. - Invalid input, startup errors, nonzero exits, timeouts, and cleanup errors
return
ToolResult(result_type="failure"), not success-shaped output.error_kindis respectivelyinvalid_input,start,exit,timeout, orcleanup; it is null on success. A command that never starts has a null exit code. - Timeout and cancellation stop the owned process and descendants using a
process group on POSIX or a kill-on-close job on Windows, then allow up to
cleanup_timeout_sfor output draining. Partial output is retained. Cancellation still propagates asCommandCancelledError(anasyncio.CancelledError), with the partialToolResultin itsresultattribute anderror_kind="cancelled"unless cleanup also failed. Processes must not deliberately escape their group or job.
Set the caller-selected terminate_descendants=False only when the consumer
owns a service started by the command and must preserve its state before
cleaning it up separately. The helper then stops only the root command process,
on both timeout and cancellation. The service must not keep the command's
standard streams open; it must own its input/output and lifecycle. Otherwise
output draining can hit the cleanup limit and reports a cleanup failure.
This option is never model input.
There are no automatic retries or persistent launcher. The helper never keeps the root command running after its tool call returns.
Permissions and independent checks¶
This helper is not a security sandbox. The selected CLI can access the host filesystem and network, and some CLIs can themselves execute arbitrary code. Consumers own application-specific permissions and guarded adapters. Do not give the model environment secrets or credentials through command arguments or standard input; command input and output can appear in saved evidence.
Use an approved execution environment and independently check the application state. Neither a successful tool call nor session completion proves the task. An MCP server is appropriate when testing an MCP interface, not a requirement for running a CLI directly.
Tests specifically about shell features¶
Use native shell tools only when the interface under test really is a shell,
such as pipelines, redirection, shell expansion, or shell scripts. Keep those
cases explicitly separate from ordinary CLI tests. Windows batch files
(.bat and .cmd) require a shell and are rejected by the direct helper.
Choose a native executable instead. Do not use a shell merely to quote JSON,
paths, formulas, or ordinary command arguments.