CliServerManager.java
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
package com.github.copilot.sdk;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.copilot.sdk.json.CopilotClientOptions;
/**
* Manages the lifecycle of the Copilot CLI server process.
* <p>
* This class handles spawning the CLI server process, building command lines,
* detecting the listening port, and establishing connections.
*/
final class CliServerManager {
private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName());
private final CopilotClientOptions options;
CliServerManager(CopilotClientOptions options) {
this.options = options;
}
/**
* Starts the CLI server process.
*
* @return information about the started process including detected port
* @throws IOException
* if the process cannot be started
* @throws InterruptedException
* if interrupted while waiting for port detection
*/
ProcessInfo startCliServer() throws IOException, InterruptedException {
String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot";
var args = new ArrayList<String>();
if (options.getCliArgs() != null) {
args.addAll(Arrays.asList(options.getCliArgs()));
}
args.add("--server");
args.add("--no-auto-update");
args.add("--log-level");
args.add(options.getLogLevel());
if (options.isUseStdio()) {
args.add("--stdio");
} else if (options.getPort() > 0) {
args.add("--port");
args.add(String.valueOf(options.getPort()));
}
// Add auth-related flags
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
args.add("--auth-token-env");
args.add("COPILOT_SDK_AUTH_TOKEN");
}
// Default UseLoggedInUser to false when GithubToken is provided
boolean useLoggedInUser = options.getUseLoggedInUser() != null
? options.getUseLoggedInUser()
: (options.getGithubToken() == null || options.getGithubToken().isEmpty());
if (!useLoggedInUser) {
args.add("--no-auto-login");
}
List<String> command = resolveCliCommand(cliPath, args);
var pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
if (options.getCwd() != null) {
pb.directory(new File(options.getCwd()));
}
if (options.getEnvironment() != null) {
pb.environment().clear();
pb.environment().putAll(options.getEnvironment());
}
pb.environment().remove("NODE_DEBUG");
// Set auth token in environment if provided
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGithubToken());
}
Process process = pb.start();
// Forward stderr to logger in background
startStderrReader(process);
Integer detectedPort = null;
if (!options.isUseStdio()) {
detectedPort = waitForPortAnnouncement(process);
}
return new ProcessInfo(process, detectedPort);
}
/**
* Connects to a running Copilot server.
*
* @param process
* the CLI process (null if connecting to external server)
* @param tcpHost
* the host to connect to (null for stdio mode)
* @param tcpPort
* the port to connect to (null for stdio mode)
* @return the JSON-RPC client connected to the server
* @throws IOException
* if connection fails
*/
JsonRpcClient connectToServer(Process process, String tcpHost, Integer tcpPort) throws IOException {
if (tcpHost != null && tcpPort != null) {
// TCP mode: external server or child process with explicit port
Socket socket = new Socket(tcpHost, tcpPort);
return JsonRpcClient.fromSocket(socket);
} else if (process != null) {
// Stdio mode: child process
return JsonRpcClient.fromProcess(process);
} else {
throw new IllegalStateException("Cannot connect: no process for stdio and no host:port for TCP");
}
}
private void startStderrReader(Process process) {
var stderrThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
LOG.fine("[CLI] " + line);
}
} catch (IOException e) {
LOG.log(Level.FINE, "Error reading stderr", e);
}
}, "cli-stderr-reader");
stderrThread.setDaemon(true);
stderrThread.start();
}
private Integer waitForPortAnnouncement(Process process) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
Pattern portPattern = Pattern.compile("listening on port (\\d+)", Pattern.CASE_INSENSITIVE);
long deadline = System.currentTimeMillis() + 30000;
while (System.currentTimeMillis() < deadline) {
String line = reader.readLine();
if (line == null) {
throw new IOException("CLI process exited unexpectedly");
}
Matcher matcher = portPattern.matcher(line);
if (matcher.find()) {
return Integer.parseInt(matcher.group(1));
}
}
process.destroyForcibly();
throw new IOException("Timeout waiting for CLI to announce port");
}
}
private List<String> resolveCliCommand(String cliPath, List<String> args) {
boolean isJsFile = cliPath.toLowerCase().endsWith(".js");
if (isJsFile) {
var result = new ArrayList<String>();
result.add("node");
result.add(cliPath);
result.addAll(args);
return result;
}
// On Windows, use cmd /c to resolve the executable
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win") && !new File(cliPath).isAbsolute()) {
var result = new ArrayList<String>();
result.add("cmd");
result.add("/c");
result.add(cliPath);
result.addAll(args);
return result;
}
var result = new ArrayList<String>();
result.add(cliPath);
result.addAll(args);
return result;
}
static URI parseCliUrl(String url) {
// If it's just a port number, treat as localhost
try {
int port = Integer.parseInt(url);
return URI.create("http://localhost:" + port);
} catch (NumberFormatException e) {
// Not a port number, continue
}
// Add scheme if missing
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) {
url = "https://" + url;
}
return URI.create(url);
}
/**
* Information about a started CLI server process.
*
* @param process
* the CLI process
* @param port
* the detected TCP port (null for stdio mode)
*/
record ProcessInfo(Process process, Integer port) {
}
}