Add GitHub Copilot SDK integration for copilot-cli backend

Co-authored-by: htekdev <100806365+htekdev@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-01-28 21:46:57 +00:00
parent 2f183e012b
commit 0e28bf70cd
13 changed files with 996 additions and 3 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.molt.bot
Status: beta.
### Changes
- Agents: add GitHub Copilot SDK integration for `copilot-cli` backend. Supports programmatic control of the Copilot CLI via `@github/copilot-sdk`.
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
- Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev.
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).

View File

@ -200,6 +200,25 @@ Moltbot also ships a default for `codex-cli`:
- `imageArg: "--image"`
- `sessionMode: "existing"`
Moltbot also ships a default for `copilot-cli`:
- `command: "copilot"`
- `sessionMode: "always"`
- Uses the `@github/copilot-sdk` for programmatic control via JSON-RPC
- Supports models: `gpt-5`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `o1`, `o1-mini`, `o3-mini`, `claude-sonnet-4.5`
To use Copilot CLI:
```bash
# Install the CLI globally
npm install -g @github/copilot
# Authenticate
copilot auth login
# Use in Moltbot
moltbot agent --message "hi" --model copilot-cli/gpt-4.1
```
Override only if needed (common: absolute `command` path).
## Limitations

View File

@ -3,6 +3,7 @@ summary: "Sign in to GitHub Copilot from Moltbot using the device flow"
read_when:
- You want to use GitHub Copilot as a model provider
- You need the `moltbot models auth login-github-copilot` flow
- You want to use the Copilot CLI as a backend
---
# Github Copilot
@ -10,9 +11,9 @@ read_when:
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Moltbot can use Copilot as a model
provider in two different ways.
provider in three different ways.
## Two ways to use Copilot in Moltbot
## Three ways to use Copilot in Moltbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
@ -20,7 +21,17 @@ Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Moltbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
### 2) Copilot CLI backend (`copilot-cli`)
Use the official [GitHub Copilot CLI](https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line)
as a CLI backend via the `@github/copilot-sdk`. This provides access to Copilot's
coding agent capabilities with tool execution and session persistence.
Prerequisites:
- Install the Copilot CLI: `npm install -g @github/copilot`
- Authenticate: `copilot auth login`
### 3) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Moltbot talks to
the proxys `/v1` endpoint and uses the model list you configure there. Choose
@ -68,3 +79,43 @@ moltbot models set github-copilot/gpt-4o
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Moltbot runs.
## Copilot CLI backend configuration
To use the Copilot CLI as a backend (instead of the API), configure it in your
`moltbot.config.json`:
```json5
{
agents: {
defaults: {
model: { primary: "copilot-cli/gpt-4.1" },
cliBackends: {
"copilot-cli": {
command: "copilot",
// Optional: customize the CLI path
// command: "/usr/local/bin/copilot"
}
}
}
}
}
```
### Available models via Copilot CLI
The Copilot CLI supports the following models (availability depends on your plan):
- `gpt-5`
- `gpt-4.1`
- `gpt-4.1-mini`
- `gpt-4.1-nano`
- `gpt-4o`
- `o1`
- `o1-mini`
- `o3-mini`
- `claude-sonnet-4.5`
- `claude-sonnet-4`
The Copilot CLI backend uses the `@github/copilot-sdk` for programmatic control
of the CLI via JSON-RPC.

View File

@ -158,6 +158,7 @@
"@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
"@github/copilot-sdk": "^0.1.19",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",

View File

@ -7,6 +7,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json";
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
export const COPILOT_CLI_PROFILE_ID = "github-copilot:copilot-cli";
export const AUTH_STORE_LOCK_OPTIONS = {
retries: {

View File

@ -74,6 +74,37 @@ const DEFAULT_CODEX_BACKEND: CliBackendConfig = {
serialize: true,
};
const COPILOT_MODEL_ALIASES: Record<string, string> = {
"gpt-5": "gpt-5",
"gpt-4.1": "gpt-4.1",
"gpt-4.1-mini": "gpt-4.1-mini",
"gpt-4.1-nano": "gpt-4.1-nano",
"gpt-4o": "gpt-4o",
"o1": "o1",
"o1-mini": "o1-mini",
"o3-mini": "o3-mini",
"claude-sonnet-4.5": "claude-sonnet-4.5",
"claude-sonnet-4": "claude-sonnet-4",
};
/**
* Default configuration for the Copilot CLI backend.
*
* Note: The Copilot CLI uses the `@github/copilot-sdk` for programmatic control.
* This backend config is used for CLI-style invocation patterns, but the actual
* execution is handled by the SDK in `copilot-runner.ts`.
*/
const DEFAULT_COPILOT_BACKEND: CliBackendConfig = {
command: "copilot",
args: [],
output: "text",
input: "arg",
modelAliases: COPILOT_MODEL_ALIASES,
sessionMode: "always",
sessionIdFields: ["sessionId", "session_id"],
serialize: true,
};
function normalizeBackendKey(key: string): string {
return normalizeProviderId(key);
}
@ -107,6 +138,7 @@ export function resolveCliBackendIds(cfg?: MoltbotConfig): Set<string> {
const ids = new Set<string>([
normalizeBackendKey("claude-cli"),
normalizeBackendKey("codex-cli"),
normalizeBackendKey("copilot-cli"),
]);
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
for (const key of Object.keys(configured)) {
@ -135,6 +167,12 @@ export function resolveCliBackendConfig(
if (!command) return null;
return { id: normalized, config: { ...merged, command } };
}
if (normalized === "copilot-cli") {
const merged = mergeBackendConfig(DEFAULT_COPILOT_BACKEND, override);
const command = merged.command?.trim();
if (!command) return null;
return { id: normalized, config: { ...merged, command } };
}
if (!override) return null;
const command = override.command?.trim();

View File

@ -26,9 +26,11 @@ import {
resolveSystemPromptUsage,
writeCliImages,
} from "./cli-runner/helpers.js";
import { runCopilotCliAgent, isCopilotCliAvailable } from "./copilot-runner.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { normalizeProviderId } from "./model-selection.js";
const log = createSubsystemLogger("agent/claude-cli");
@ -50,6 +52,29 @@ export async function runCliAgent(params: {
cliSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
// Route copilot-cli requests to the SDK-based runner
const normalizedProvider = normalizeProviderId(params.provider);
if (normalizedProvider === "copilot-cli") {
return runCopilotCliAgent({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.config,
prompt: params.prompt,
provider: params.provider,
model: params.model,
thinkLevel: params.thinkLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
extraSystemPrompt: params.extraSystemPrompt,
streamParams: params.streamParams,
ownerNumbers: params.ownerNumbers,
cliSessionId: params.cliSessionId,
images: params.images,
});
}
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
@ -333,3 +358,6 @@ export async function runClaudeCliAgent(params: {
images: params.images,
});
}
// Re-export Copilot-specific utilities
export { isCopilotCliAvailable } from "./copilot-runner.js";

View File

@ -0,0 +1,143 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
isCopilotCliInstalled,
readCopilotAuthStatus,
readCopilotAuthStatusCached,
resetCopilotCredentialCacheForTest,
} from "./copilot-credentials.js";
describe("copilot-credentials", () => {
beforeEach(() => {
resetCopilotCredentialCacheForTest();
});
describe("isCopilotCliInstalled", () => {
it("returns true when copilot --version succeeds", () => {
const execSync = vi.fn().mockReturnValue("0.0.1");
const result = isCopilotCliInstalled({ execSync });
expect(result).toBe(true);
expect(execSync).toHaveBeenCalledWith("copilot --version", expect.any(Object));
});
it("returns false when copilot --version fails", () => {
const execSync = vi.fn().mockImplementation(() => {
throw new Error("ENOENT: not found");
});
const result = isCopilotCliInstalled({ execSync });
expect(result).toBe(false);
});
it("uses custom cliPath when provided", () => {
const execSync = vi.fn().mockReturnValue("0.0.1");
isCopilotCliInstalled({ cliPath: "/usr/local/bin/copilot", execSync });
expect(execSync).toHaveBeenCalledWith(
"/usr/local/bin/copilot --version",
expect.any(Object),
);
});
});
describe("readCopilotAuthStatus", () => {
it("returns authenticated status when CLI reports authenticated", () => {
const execSync = vi.fn().mockReturnValue(
JSON.stringify({
isAuthenticated: true,
user: {
login: "testuser",
avatarUrl: "https://example.com/avatar.png",
},
}),
);
const result = readCopilotAuthStatus({ execSync });
expect(result).toEqual({
authenticated: true,
login: "testuser",
avatarUrl: "https://example.com/avatar.png",
});
});
it("returns not authenticated when CLI reports not authenticated", () => {
const execSync = vi.fn().mockReturnValue(
JSON.stringify({
isAuthenticated: false,
}),
);
const result = readCopilotAuthStatus({ execSync });
expect(result).toEqual({ authenticated: false });
});
it("returns null when CLI is not installed", () => {
const execSync = vi.fn().mockImplementation(() => {
const error = new Error("ENOENT: not found");
throw error;
});
const result = readCopilotAuthStatus({ execSync });
expect(result).toBe(null);
});
it("returns not authenticated on stderr indicating not logged in", () => {
const execSync = vi.fn().mockImplementation(() => {
const error = new Error("Command failed") as Error & { stderr: string };
error.stderr = "You are not logged in";
throw error;
});
const result = readCopilotAuthStatus({ execSync });
expect(result).toEqual({ authenticated: false });
});
});
describe("readCopilotAuthStatusCached", () => {
it("returns cached value within TTL", () => {
const execSync = vi.fn().mockReturnValue(
JSON.stringify({
isAuthenticated: true,
user: { login: "testuser" },
}),
);
// First call populates cache
const result1 = readCopilotAuthStatusCached({ execSync, ttlMs: 60000 });
expect(result1?.authenticated).toBe(true);
expect(execSync).toHaveBeenCalledTimes(1);
// Second call should use cache
const result2 = readCopilotAuthStatusCached({ execSync, ttlMs: 60000 });
expect(result2?.authenticated).toBe(true);
expect(execSync).toHaveBeenCalledTimes(1); // Not called again
});
it("refreshes cache after TTL expires", async () => {
const execSync = vi.fn().mockReturnValue(
JSON.stringify({
isAuthenticated: true,
user: { login: "testuser" },
}),
);
// First call with very short TTL
readCopilotAuthStatusCached({ execSync, ttlMs: 1 });
expect(execSync).toHaveBeenCalledTimes(1);
// Wait for TTL to expire
await new Promise((r) => setTimeout(r, 5));
// Second call should refresh
readCopilotAuthStatusCached({ execSync, ttlMs: 1 });
expect(execSync).toHaveBeenCalledTimes(2);
});
it("does not cache when ttlMs is 0", () => {
const execSync = vi.fn().mockReturnValue(
JSON.stringify({
isAuthenticated: true,
user: { login: "testuser" },
}),
);
readCopilotAuthStatusCached({ execSync, ttlMs: 0 });
readCopilotAuthStatusCached({ execSync, ttlMs: 0 });
expect(execSync).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -0,0 +1,168 @@
/**
* Credential management for the GitHub Copilot CLI.
*
* This module provides functions to check the authentication status of the Copilot CLI
* and cache the results for efficient repeated access.
*/
import { execSync } from "node:child_process";
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("agents/copilot-credentials");
/**
* Cached authentication status from the Copilot CLI.
*/
export type CopilotCredential = {
/** Whether the CLI is authenticated with a valid GitHub account. */
authenticated: boolean;
/** GitHub login username (if authenticated). */
login?: string;
/** GitHub avatar URL (if authenticated). */
avatarUrl?: string;
};
type CachedValue<T> = {
value: T | null;
readAt: number;
cacheKey: string;
};
let copilotCliCache: CachedValue<CopilotCredential> | null = null;
/** Reset the cache for testing purposes. */
export function resetCopilotCredentialCacheForTest(): void {
copilotCliCache = null;
}
type ExecSyncFn = typeof execSync;
/**
* Check if the Copilot CLI is installed and available on PATH.
*/
export function isCopilotCliInstalled(options?: {
cliPath?: string;
execSync?: ExecSyncFn;
}): boolean {
const execSyncImpl = options?.execSync ?? execSync;
const cliPath = options?.cliPath ?? "copilot";
try {
execSyncImpl(`${cliPath} --version`, {
encoding: "utf8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
});
return true;
} catch {
return false;
}
}
/**
* Read the authentication status from the Copilot CLI.
*
* Requires the `copilot` CLI to be installed. If the CLI is not installed
* or authentication status cannot be determined, returns null.
*/
export function readCopilotAuthStatus(options?: {
cliPath?: string;
execSync?: ExecSyncFn;
}): CopilotCredential | null {
const execSyncImpl = options?.execSync ?? execSync;
const cliPath = options?.cliPath ?? "copilot";
try {
// The Copilot CLI has a `copilot auth status --json` command that returns auth info.
const result = execSyncImpl(`${cliPath} auth status --json`, {
encoding: "utf8",
timeout: 10000,
stdio: ["pipe", "pipe", "pipe"],
});
const data = JSON.parse(result.trim()) as Record<string, unknown>;
// The CLI returns { isAuthenticated: boolean, user?: { login, avatarUrl } }
const isAuthenticated = data.isAuthenticated === true;
const user = data.user as Record<string, unknown> | undefined;
if (!isAuthenticated) {
log.info("copilot cli is not authenticated");
return { authenticated: false };
}
const login = typeof user?.login === "string" ? user.login : undefined;
const avatarUrl = typeof user?.avatarUrl === "string" ? user.avatarUrl : undefined;
log.info("read copilot auth status from cli", {
authenticated: true,
login,
});
return {
authenticated: true,
login,
avatarUrl,
};
} catch (error) {
// Check if it's a "not installed" error vs an auth error
const message = error instanceof Error ? error.message : String(error);
if (message.includes("ENOENT") || message.includes("not found")) {
log.debug("copilot cli not found");
return null;
}
// Parse error response - CLI may return JSON even on error
try {
const stderr =
error instanceof Error && "stderr" in error
? String((error as { stderr: unknown }).stderr)
: "";
if (stderr.includes("not logged in") || stderr.includes("not authenticated")) {
return { authenticated: false };
}
} catch {
// Ignore parse errors
}
log.warn("failed to read copilot auth status", {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Read the Copilot CLI authentication status with caching.
*
* @param options.ttlMs - How long to cache the result in milliseconds (default: no caching)
*/
export function readCopilotAuthStatusCached(options?: {
cliPath?: string;
ttlMs?: number;
execSync?: ExecSyncFn;
}): CopilotCredential | null {
const ttlMs = options?.ttlMs ?? 0;
const now = Date.now();
const cacheKey = options?.cliPath ?? "copilot";
if (
ttlMs > 0 &&
copilotCliCache &&
copilotCliCache.cacheKey === cacheKey &&
now - copilotCliCache.readAt < ttlMs
) {
return copilotCliCache.value;
}
const value = readCopilotAuthStatus({
cliPath: options?.cliPath,
execSync: options?.execSync,
});
if (ttlMs > 0) {
copilotCliCache = { value, readAt: now, cacheKey };
}
return value;
}

View File

@ -0,0 +1,160 @@
/**
* Copilot CLI Runner - runs agent prompts through the GitHub Copilot SDK.
*
* This module provides a runner that uses the `@github/copilot-sdk` to execute
* agent prompts through the Copilot CLI, similar to how `cli-runner.ts` handles
* Claude CLI and Codex CLI backends.
*/
import type { ImageContent } from "@mariozechner/pi-ai";
import type { MoltbotConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import { normalizeCliModel, buildSystemPrompt } from "./cli-runner/helpers.js";
import { runCopilotAgent, checkCopilotAvailable } from "./copilot-sdk.js";
import { resolveMoltbotDocsPath } from "./docs-path.js";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
const log = createSubsystemLogger("agent/copilot-cli");
/**
* Check if the Copilot CLI backend is available.
*/
export function isCopilotCliAvailable(options?: { cliPath?: string }): boolean {
const status = checkCopilotAvailable({ cliPath: options?.cliPath });
return status.available && status.authenticated;
}
/**
* Run an agent prompt through the Copilot CLI using the SDK.
*
* This function is designed to match the signature of `runCliAgent` for
* compatibility with the existing CLI backend infrastructure.
*/
export async function runCopilotCliAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: MoltbotConfig;
prompt: string;
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
streamParams?: import("../commands/agent/types.js").AgentStreamParams;
ownerNumbers?: string[];
cliSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const provider = params.provider ?? "copilot-cli";
const backendResolved = resolveCliBackendConfig(provider, params.config);
if (!backendResolved) {
throw new Error(`Unknown CLI backend: ${provider}`);
}
const backend = backendResolved.config;
const modelId = (params.model ?? "gpt-4.1").trim() || "gpt-4.1";
const normalizedModel = normalizeCliModel(modelId, backend);
const modelDisplay = `${provider}/${modelId}`;
// Build system prompt with context
const extraSystemPrompt = [
params.extraSystemPrompt?.trim(),
"Tools are disabled in this session. Do not call tools.",
]
.filter(Boolean)
.join("\n");
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
workspaceDir,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const heartbeatPrompt =
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined;
const docsPath = await resolveMoltbotDocsPath({
workspaceDir,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
docsPath: docsPath ?? undefined,
tools: [],
contextFiles,
modelDisplay,
agentId: sessionAgentId,
});
log.info(`copilot-cli exec: model=${normalizedModel} promptChars=${params.prompt.length}`);
try {
const result = await runCopilotAgent({
prompt: params.prompt,
model: normalizedModel,
cliPath: backend.command,
cwd: workspaceDir,
systemPrompt,
timeoutMs: params.timeoutMs,
sessionId: params.cliSessionId,
onEvent: (event) => {
// Forward streaming events if streamParams is provided
if (params.streamParams?.onDelta && event.type === "assistant.message_delta") {
const data = event.data as { content?: string };
if (typeof data.content === "string") {
params.streamParams.onDelta(data.content);
}
}
},
});
const text = result.text?.trim();
const payloads = text ? [{ text }] : undefined;
return {
payloads,
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: result.sessionId ?? params.sessionId ?? "",
provider,
model: modelId,
},
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error("copilot-cli run failed", { error: message });
throw err;
}
}

View File

@ -0,0 +1,90 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { checkCopilotAvailable } from "./copilot-sdk.js";
import * as copilotCredentials from "./copilot-credentials.js";
vi.mock("./copilot-credentials.js", () => ({
isCopilotCliInstalled: vi.fn(),
readCopilotAuthStatusCached: vi.fn(),
}));
describe("copilot-sdk", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("checkCopilotAvailable", () => {
it("returns not available when CLI is not installed", () => {
vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(false);
const result = checkCopilotAvailable();
expect(result).toEqual({
available: false,
authenticated: false,
});
});
it("returns available but not authenticated when CLI is installed but not authenticated", () => {
vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true);
vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({
authenticated: false,
});
const result = checkCopilotAvailable();
expect(result).toEqual({
available: true,
authenticated: false,
});
});
it("returns available and authenticated with user info when CLI is authenticated", () => {
vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true);
vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({
authenticated: true,
login: "testuser",
avatarUrl: "https://example.com/avatar.png",
});
const result = checkCopilotAvailable();
expect(result).toEqual({
available: true,
authenticated: true,
login: "testuser",
avatarUrl: "https://example.com/avatar.png",
});
});
it("returns available but not authenticated when auth status is null", () => {
vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true);
vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue(null);
const result = checkCopilotAvailable();
expect(result).toEqual({
available: true,
authenticated: false,
});
});
it("passes cliPath option to credential functions", () => {
vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true);
vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({
authenticated: true,
});
checkCopilotAvailable({ cliPath: "/custom/path/copilot" });
expect(copilotCredentials.isCopilotCliInstalled).toHaveBeenCalledWith({
cliPath: "/custom/path/copilot",
});
expect(copilotCredentials.readCopilotAuthStatusCached).toHaveBeenCalledWith(
expect.objectContaining({
cliPath: "/custom/path/copilot",
}),
);
});
});
});

292
src/agents/copilot-sdk.ts Normal file
View File

@ -0,0 +1,292 @@
/**
* GitHub Copilot SDK integration for Moltbot.
*
* This module provides a thin wrapper around the `@github/copilot-sdk` package
* for programmatic control of GitHub Copilot CLI via JSON-RPC.
*/
import type {
CopilotClient,
CopilotClientOptions,
CopilotSession,
SessionConfig,
SessionEvent,
} from "@github/copilot-sdk";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isCopilotCliInstalled, readCopilotAuthStatusCached } from "./copilot-credentials.js";
const log = createSubsystemLogger("agents/copilot-sdk");
// Re-export SDK types that consumers may need.
export type {
CopilotClient,
CopilotClientOptions,
CopilotSession,
SessionConfig,
SessionEvent,
};
/**
* Options for creating a Moltbot-configured Copilot client.
*/
export type MoltbotCopilotClientOptions = {
/** Path to the Copilot CLI executable (default: "copilot" from PATH). */
cliPath?: string;
/** Working directory for the CLI process. */
cwd?: string;
/** Log level for the CLI server. */
logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all";
/** Auto-restart the CLI server if it crashes (default: true). */
autoRestart?: boolean;
/** Environment variables to pass to the CLI process. */
env?: Record<string, string | undefined>;
};
/**
* Check if the Copilot CLI is available and authenticated.
*
* @returns Object with `available` and `authenticated` flags, plus optional user info.
*/
export function checkCopilotAvailable(options?: { cliPath?: string }): {
available: boolean;
authenticated: boolean;
login?: string;
avatarUrl?: string;
} {
const installed = isCopilotCliInstalled({ cliPath: options?.cliPath });
if (!installed) {
return { available: false, authenticated: false };
}
const authStatus = readCopilotAuthStatusCached({
cliPath: options?.cliPath,
ttlMs: 5 * 60 * 1000, // Cache for 5 minutes
});
if (!authStatus) {
return { available: true, authenticated: false };
}
return {
available: true,
authenticated: authStatus.authenticated,
login: authStatus.login,
avatarUrl: authStatus.avatarUrl,
};
}
/**
* Create a CopilotClient with Moltbot-specific defaults.
*
* This is a factory function that lazily imports the SDK and creates
* a client instance. The client should be started before use.
*/
export async function createCopilotClient(
options?: MoltbotCopilotClientOptions,
): Promise<CopilotClient> {
const { CopilotClient: CopilotClientClass } = await import("@github/copilot-sdk");
const clientOptions: CopilotClientOptions = {
cliPath: options?.cliPath ?? "copilot",
cwd: options?.cwd,
logLevel: options?.logLevel ?? "warning",
autoRestart: options?.autoRestart ?? true,
useStdio: true, // Use stdio transport for better process control
autoStart: false, // We'll start manually for better error handling
env: options?.env,
};
log.info("creating copilot client", {
cliPath: clientOptions.cliPath,
cwd: clientOptions.cwd,
logLevel: clientOptions.logLevel,
});
return new CopilotClientClass(clientOptions);
}
/**
* Parameters for running a single-turn Copilot agent interaction.
*/
export type RunCopilotAgentParams = {
/** The prompt/message to send. */
prompt: string;
/** Model to use (e.g., "gpt-5", "gpt-4.1", "claude-sonnet-4.5"). */
model?: string;
/** Path to the Copilot CLI executable. */
cliPath?: string;
/** Working directory for the CLI process. */
cwd?: string;
/** System message content (appended to CLI defaults). */
systemPrompt?: string;
/** Timeout in milliseconds (default: 120000). */
timeoutMs?: number;
/** Session ID to resume (for multi-turn conversations). */
sessionId?: string;
/** Environment variables to pass to the CLI. */
env?: Record<string, string | undefined>;
/** Callback for streaming events. */
onEvent?: (event: SessionEvent) => void;
};
/**
* Result from a Copilot agent run.
*/
export type CopilotAgentResult = {
/** The final assistant response text. */
text: string;
/** Session ID for resuming conversations. */
sessionId: string;
/** Events received during the run. */
events: SessionEvent[];
/** Duration in milliseconds. */
durationMs: number;
};
/**
* Run a single-turn Copilot agent interaction.
*
* Creates a client, starts a session, sends the prompt, waits for completion,
* and cleans up. For multi-turn conversations, pass the returned `sessionId`
* back in subsequent calls.
*/
export async function runCopilotAgent(
params: RunCopilotAgentParams,
): Promise<CopilotAgentResult> {
const started = Date.now();
const events: SessionEvent[] = [];
let finalText = "";
const client = await createCopilotClient({
cliPath: params.cliPath,
cwd: params.cwd,
env: params.env,
});
try {
// Start the client
await client.start();
// Configure session
const sessionConfig: SessionConfig = {
model: params.model,
sessionId: params.sessionId,
};
// Add system message if provided
if (params.systemPrompt) {
sessionConfig.systemMessage = {
mode: "append",
content: params.systemPrompt,
};
}
// Create or resume session
let session: CopilotSession;
if (params.sessionId) {
session = await client.resumeSession(params.sessionId, {
streaming: true,
});
} else {
session = await client.createSession(sessionConfig);
}
const sessionId = session.sessionId;
// Set up event handler
const unsubscribe = session.on((event: SessionEvent) => {
events.push(event);
params.onEvent?.(event);
// Capture final text from assistant message events
if (event.type === "assistant.message") {
const data = event.data as { content?: string };
if (typeof data.content === "string") {
finalText = data.content;
}
}
});
try {
// Send the message and wait for completion
const result = await session.sendAndWait(
{ prompt: params.prompt },
params.timeoutMs ?? 120000,
);
// Extract text from result if available
if (result?.data?.content && typeof result.data.content === "string") {
finalText = result.data.content;
}
return {
text: finalText,
sessionId,
events,
durationMs: Date.now() - started,
};
} finally {
unsubscribe();
// Note: We don't destroy the session if sessionId was provided for resumption
if (!params.sessionId) {
await session.destroy().catch(() => {
// Ignore cleanup errors
});
}
}
} finally {
// Always stop the client
await client.stop().catch(() => {
// Ignore cleanup errors
});
}
}
/**
* List available Copilot sessions.
*/
export async function listCopilotSessions(options?: {
cliPath?: string;
cwd?: string;
}): Promise<Array<{ sessionId: string; model?: string; createdAt?: string }>> {
const client = await createCopilotClient({
cliPath: options?.cliPath,
cwd: options?.cwd,
});
try {
await client.start();
const sessions = await client.listSessions();
return sessions.map((s) => ({
sessionId: s.sessionId,
model: s.model,
createdAt: s.createdAt,
}));
} finally {
await client.stop().catch(() => {});
}
}
/**
* Delete a Copilot session.
*/
export async function deleteCopilotSession(
sessionId: string,
options?: {
cliPath?: string;
cwd?: string;
},
): Promise<void> {
const client = await createCopilotClient({
cliPath: options?.cliPath,
cwd: options?.cwd,
});
try {
await client.start();
await client.deleteSession(sessionId);
log.info("deleted copilot session", { sessionId });
} finally {
await client.stop().catch(() => {});
}
}

View File

@ -36,6 +36,7 @@ export function isCliProvider(provider: string, cfg?: MoltbotConfig): boolean {
const normalized = normalizeProviderId(provider);
if (normalized === "claude-cli") return true;
if (normalized === "codex-cli") return true;
if (normalized === "copilot-cli") return true;
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized);
}