Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 0 additions & 1 deletion .github/workflows/dotnet-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,4 @@ jobs:
- name: Run .NET SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }}
run: dotnet test --no-build -v n
3 changes: 3 additions & 0 deletions dotnet/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
bin/
obj/

# Generated build props (contains CLI version)
src/build/GitHub.Copilot.SDK.props

# NuGet packages
*.nupkg
*.snupkg
Expand Down
19 changes: 11 additions & 8 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,9 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio

private static async Task<(Process Process, int? DetectedLocalhostTcpPort)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken)
{
var cliPath = options.CliPath ?? "copilot";
// Use explicit path or bundled CLI - no PATH fallback
var cliPath = options.CliPath ?? GetBundledCliPath(out var searchedPath)
Comment thread
SteveSandersonMS marked this conversation as resolved.
?? throw new InvalidOperationException($"Copilot CLI not found at '{searchedPath}'. Ensure the SDK NuGet package was restored correctly or provide an explicit CliPath.");
Comment thread
SteveSandersonMS marked this conversation as resolved.
Comment thread
SteveSandersonMS marked this conversation as resolved.
var args = new List<string>();

if (options.CliArgs != null)
Expand Down Expand Up @@ -970,6 +972,14 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
return (cliProcess, detectedLocalhostTcpPort);
}

private static string? GetBundledCliPath(out string searchedPath)
{
var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot";
var rid = Path.GetFileName(System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier);
searchedPath = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", binaryName);
Comment thread
SteveSandersonMS marked this conversation as resolved.
Dismissed
return File.Exists(searchedPath) ? searchedPath : null;
Comment thread
SteveSandersonMS marked this conversation as resolved.
Comment thread
SteveSandersonMS marked this conversation as resolved.
}

private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(string cliPath, IEnumerable<string> args)
{
var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase);
Expand All @@ -979,13 +989,6 @@ private static (string FileName, IEnumerable<string> Args) ResolveCliCommand(str
return ("node", new[] { cliPath }.Concat(args));
Comment thread
SteveSandersonMS marked this conversation as resolved.
}

// On Windows with UseShellExecute=false, Process.Start doesn't search PATHEXT,
// so use cmd /c to let the shell resolve the executable
if (OperatingSystem.IsWindows() && !Path.IsPathRooted(cliPath))
{
return ("cmd", new[] { "/c", cliPath }.Concat(args));
}

return (cliPath, args);
}

Expand Down
73 changes: 49 additions & 24 deletions dotnet/src/GitHub.Copilot.SDK.csproj
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>0.1.0</Version>
<Description>SDK for programmatic control of GitHub Copilot CLI</Description>
<Authors>GitHub</Authors>
<Company>GitHub</Company>
<Copyright>Copyright (c) Microsoft Corporation. All rights reserved.</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/github/copilot-sdk</RepositoryUrl>
<PackageTags>github;copilot;sdk;jsonrpc;agent</PackageTags>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="StreamJsonRpc" Version="2.24.84" PrivateAssets="compile" />
<PackageReference Include="System.Text.Json" Version="10.0.2" />
</ItemGroup>

<!-- Generate version props file at build time (gitignored) -->
<Target Name="_GenerateVersionProps" BeforeTargets="BeforeBuild">
<Exec Command="node -e &quot;console.log(require('./nodejs/package-lock.json').packages['node_modules/@github/copilot'].version)&quot;" WorkingDirectory="$(MSBuildThisFileDirectory)../.." ConsoleToMSBuild="true" StandardOutputImportance="low">
Comment thread
SteveSandersonMS marked this conversation as resolved.
<Output TaskParameter="ConsoleOutput" PropertyName="CopilotCliVersion" />
</Exec>
<Error Condition="'$(CopilotCliVersion)' == ''" Text="CopilotCliVersion could not be read from nodejs/package-lock.json" />
Comment thread
SteveSandersonMS marked this conversation as resolved.
<PropertyGroup>
<_VersionPropsContent>
<![CDATA[<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>0.1.0</Version>
<Description>SDK for programmatic control of GitHub Copilot CLI</Description>
<Authors>GitHub</Authors>
<Company>GitHub</Company>
<Copyright>Copyright (c) Microsoft Corporation. All rights reserved.</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/github/copilot-sdk</RepositoryUrl>
<PackageTags>github;copilot;sdk;jsonrpc;agent</PackageTags>
<IsAotCompatible>true</IsAotCompatible>
<CopilotCliVersion>$(CopilotCliVersion)</CopilotCliVersion>
</PropertyGroup>
</Project>]]>
</_VersionPropsContent>
</PropertyGroup>
<WriteLinesToFile File="$(MSBuildThisFileDirectory)build\GitHub.Copilot.SDK.props" Lines="$(_VersionPropsContent)" Overwrite="true" WriteOnlyWhenDifferent="true" />
</Target>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="StreamJsonRpc" Version="2.24.84" PrivateAssets="compile" />
<PackageReference Include="System.Text.Json" Version="10.0.2" />
</ItemGroup>
<!-- Include .targets and .props files in package -->
<!-- Also import the .targets for local dev (same logic consumers get) -->
<ItemGroup>
<None Include="build\GitHub.Copilot.SDK.*" Pack="true" PackagePath="build\" CopyToOutputDirectory="Never" />
</ItemGroup>
<Import Project="build\GitHub.Copilot.SDK.targets" />
Comment thread
SteveSandersonMS marked this conversation as resolved.

</Project>
3 changes: 3 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public enum ConnectionState

public class CopilotClientOptions
{
/// <summary>
Comment thread
SteveSandersonMS marked this conversation as resolved.
/// Path to the Copilot CLI executable. If not specified, uses the bundled CLI from the SDK.
/// </summary>
public string? CliPath { get; set; }
public string[]? CliArgs { get; set; }
public string? Cwd { get; set; }
Expand Down
84 changes: 84 additions & 0 deletions dotnet/src/build/GitHub.Copilot.SDK.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<Project>
<!-- These targets run in consuming projects when they build -->
<!-- CopilotCliVersion is imported from GitHub.Copilot.SDK.props (generated at SDK build time, packaged alongside) -->
<Import Project="$(MSBuildThisFileDirectory)GitHub.Copilot.SDK.props" Condition="'$(CopilotCliVersion)' == '' And Exists('$(MSBuildThisFileDirectory)GitHub.Copilot.SDK.props')" />

<!-- Resolve RID: use explicit RuntimeIdentifier, or infer from current machine -->
<PropertyGroup>
<_CopilotRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier)</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Windows')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">win-x64</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Windows')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">win-arm64</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">linux-x64</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">linux-arm64</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">osx-x64</_CopilotRid>
<_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">osx-arm64</_CopilotRid>
Comment thread
SteveSandersonMS marked this conversation as resolved.
</PropertyGroup>

<!-- Map RID to platform name used in npm packages -->
<PropertyGroup>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-x64'">win32-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-arm64'">win32-arm64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-x64'">linux-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'linux-arm64'">linux-arm64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-x64'">darwin-x64</_CopilotPlatform>
<_CopilotPlatform Condition="'$(_CopilotRid)' == 'osx-arm64'">darwin-arm64</_CopilotPlatform>
<_CopilotBinary Condition="$(_CopilotRid.StartsWith('win-'))">copilot.exe</_CopilotBinary>
<_CopilotBinary Condition="'$(_CopilotBinary)' == ''">copilot</_CopilotBinary>
</PropertyGroup>

<!-- Download and extract CLI binary -->
<Target Name="_DownloadCopilotCli" BeforeTargets="BeforeBuild" Condition="'$(_CopilotPlatform)' != ''">
<Error Condition="'$(CopilotCliVersion)' == ''" Text="CopilotCliVersion is not set. The GitHub.Copilot.SDK.props file may be missing from the NuGet package." />

<!-- Compute paths using version (now available) -->
<PropertyGroup>
<_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)</_CopilotCacheDir>
<_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)</_CopilotCliBinaryPath>
<_CopilotArchivePath>$(_CopilotCacheDir)\copilot.tgz</_CopilotArchivePath>
<_CopilotDownloadUrl>https://registry.npmjs.org/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz</_CopilotDownloadUrl>
</PropertyGroup>

<!-- Delete archive if binary missing (handles partial/corrupted downloads) -->
<Delete Files="$(_CopilotArchivePath)" Condition="!Exists('$(_CopilotCliBinaryPath)') And Exists('$(_CopilotArchivePath)')" />

<!-- Download if not cached -->
<MakeDir Directories="$(_CopilotCacheDir)" Condition="!Exists('$(_CopilotCliBinaryPath)')" />
<Message Importance="high" Text="Downloading Copilot CLI $(CopilotCliVersion) for $(_CopilotPlatform)..." Condition="!Exists('$(_CopilotCliBinaryPath)')" />
<DownloadFile SourceUrl="$(_CopilotDownloadUrl)" DestinationFolder="$(_CopilotCacheDir)" DestinationFileName="copilot.tgz"
Condition="!Exists('$(_CopilotCliBinaryPath)')" />

<!-- Extract using tar (use Windows system tar explicitly to avoid Git bash tar issues) -->
<PropertyGroup>
<_TarCommand Condition="$([MSBuild]::IsOSPlatform('Windows'))">$(SystemRoot)\System32\tar.exe</_TarCommand>
<_TarCommand Condition="'$(_TarCommand)' == ''">tar</_TarCommand>
</PropertyGroup>
<Exec Command="&quot;$(_TarCommand)&quot; -xzf &quot;$(_CopilotArchivePath)&quot; --strip-components=1 -C &quot;$(_CopilotCacheDir)&quot;"
Condition="!Exists('$(_CopilotCliBinaryPath)')" />

<Error Condition="!Exists('$(_CopilotCliBinaryPath)')" Text="Failed to extract Copilot CLI binary to $(_CopilotCliBinaryPath)" />
</Target>

<!-- Copy CLI binary to output runtimes folder and register for transitive copy -->
<Target Name="_CopyCopilotCliToOutput" AfterTargets="Build" DependsOnTargets="_DownloadCopilotCli" Condition="'$(_CopilotPlatform)' != ''">
<PropertyGroup>
<_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)</_CopilotCacheDir>
<_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)</_CopilotCliBinaryPath>
<_CopilotOutputDir>$(OutDir)runtimes\$(_CopilotRid)\native</_CopilotOutputDir>
</PropertyGroup>
<MakeDir Directories="$(_CopilotOutputDir)" />
<Copy SourceFiles="$(_CopilotCliBinaryPath)" DestinationFolder="$(_CopilotOutputDir)" SkipUnchangedFiles="true" />
</Target>

<!-- Register CLI binary as content so it flows through project references -->
<Target Name="_RegisterCopilotCliForCopy" BeforeTargets="GetCopyToOutputDirectoryItems" DependsOnTargets="_DownloadCopilotCli" Condition="'$(_CopilotPlatform)' != ''">
<PropertyGroup>
<_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)</_CopilotCacheDir>
<_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary)</_CopilotCliBinaryPath>
</PropertyGroup>
<ItemGroup>
<ContentWithTargetPath Include="$(_CopilotCliBinaryPath)"
TargetPath="runtimes\$(_CopilotRid)\native\$(_CopilotBinary)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Target>
</Project>
44 changes: 8 additions & 36 deletions dotnet/test/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,12 @@ namespace GitHub.Copilot.SDK.Test;

// These tests bypass E2ETestBase because they are about how the CLI subprocess is started
// Other test classes should instead inherit from E2ETestBase
public class ClientTests : IAsyncLifetime
public class ClientTests
{
private string _cliPath = null!;

public Task InitializeAsync()
{
_cliPath = GetCliPath();
return Task.CompletedTask;
}

public Task DisposeAsync() => Task.CompletedTask;

private static string GetCliPath()
{
var envPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
if (!string.IsNullOrEmpty(envPath)) return envPath;

var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var path = Path.Combine(dir.FullName, "nodejs/node_modules/@github/copilot/index.js");
if (File.Exists(path)) return path;
dir = dir.Parent;
}
throw new InvalidOperationException("CLI not found. Run 'npm install' in the nodejs directory first.");
}

[Fact]
public async Task Should_Start_And_Connect_To_Server_Using_Stdio()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true });
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true });
Comment thread
SteveSandersonMS marked this conversation as resolved.

try
{
Expand All @@ -61,7 +36,7 @@ public async Task Should_Start_And_Connect_To_Server_Using_Stdio()
[Fact]
public async Task Should_Start_And_Connect_To_Server_Using_Tcp()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = false });
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = false });

try
{
Expand All @@ -82,7 +57,7 @@ public async Task Should_Start_And_Connect_To_Server_Using_Tcp()
[Fact]
public async Task Should_Force_Stop_Without_Cleanup()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath });
using var client = new CopilotClient(new CopilotClientOptions());

await client.CreateSessionAsync();
await client.ForceStopAsync();
Expand All @@ -93,7 +68,7 @@ public async Task Should_Force_Stop_Without_Cleanup()
[Fact]
public async Task Should_Get_Status_With_Version_And_Protocol_Info()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true });
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true });

try
{
Expand All @@ -115,7 +90,7 @@ public async Task Should_Get_Status_With_Version_And_Protocol_Info()
[Fact]
public async Task Should_Get_Auth_Status()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true });
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true });

try
{
Expand All @@ -140,7 +115,7 @@ public async Task Should_Get_Auth_Status()
[Fact]
public async Task Should_List_Models_When_Authenticated()
{
using var client = new CopilotClient(new CopilotClientOptions { CliPath = _cliPath, UseStdio = true });
using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true });

try
{
Expand Down Expand Up @@ -178,7 +153,6 @@ public void Should_Accept_GithubToken_Option()
{
var options = new CopilotClientOptions
{
CliPath = _cliPath,
GithubToken = "gho_test_token"
};

Expand All @@ -188,7 +162,7 @@ public void Should_Accept_GithubToken_Option()
[Fact]
public void Should_Default_UseLoggedInUser_To_Null()
{
var options = new CopilotClientOptions { CliPath = _cliPath };
var options = new CopilotClientOptions();

Assert.Null(options.UseLoggedInUser);
}
Expand All @@ -198,7 +172,6 @@ public void Should_Allow_Explicit_UseLoggedInUser_False()
{
var options = new CopilotClientOptions
{
CliPath = _cliPath,
UseLoggedInUser = false
};

Expand All @@ -210,7 +183,6 @@ public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken()
{
var options = new CopilotClientOptions
{
CliPath = _cliPath,
GithubToken = "gho_test_token",
UseLoggedInUser = true
};
Expand Down
8 changes: 2 additions & 6 deletions dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,15 @@ namespace GitHub.Copilot.SDK.Test.Harness;

public class E2ETestContext : IAsyncDisposable
{
public string CliPath { get; }
public string HomeDir { get; }
public string WorkDir { get; }
public string ProxyUrl { get; }

private readonly CapiProxy _proxy;
private readonly string _repoRoot;

private E2ETestContext(string cliPath, string homeDir, string workDir, string proxyUrl, CapiProxy proxy, string repoRoot)
private E2ETestContext(string homeDir, string workDir, string proxyUrl, CapiProxy proxy, string repoRoot)
{
CliPath = cliPath;
HomeDir = homeDir;
WorkDir = workDir;
ProxyUrl = proxyUrl;
Expand All @@ -30,7 +28,6 @@ private E2ETestContext(string cliPath, string homeDir, string workDir, string pr
public static async Task<E2ETestContext> CreateAsync()
{
var repoRoot = FindRepoRoot();
var cliPath = GetCliPath(repoRoot);

var homeDir = Path.Combine(Path.GetTempPath(), $"copilot-test-config-{Guid.NewGuid()}");
var workDir = Path.Combine(Path.GetTempPath(), $"copilot-test-work-{Guid.NewGuid()}");
Expand All @@ -41,7 +38,7 @@ public static async Task<E2ETestContext> CreateAsync()
var proxy = new CapiProxy();
var proxyUrl = await proxy.StartAsync();

return new E2ETestContext(cliPath, homeDir, workDir, proxyUrl, proxy, repoRoot);
return new E2ETestContext(homeDir, workDir, proxyUrl, proxy, repoRoot);
}

private static string FindRepoRoot()
Expand Down Expand Up @@ -94,7 +91,6 @@ public IReadOnlyDictionary<string, string> GetEnvironment()

public CopilotClient CreateClient() => new(new CopilotClientOptions
{
CliPath = CliPath,
Cwd = WorkDir,
Environment = GetEnvironment(),
GithubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null,
Expand Down
Loading