CLI: GitHub Copilot SDK integration

This commit is contained in:
Aditya Mer 2026-01-26 11:05:15 +05:30
parent 6859e1e6a6
commit c98175fcc0
30 changed files with 864 additions and 3485 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
docs/developer-guide.md Normal file
View File

@ -0,0 +1,15 @@
---
title: Developer guide
---
## Copilot SDK integration
When integrating GitHub Copilot, use the official `@github/copilot-sdk` package and avoid any other `@github/copilot*` packages. This keeps auditing simple and avoids divergent behavior.
Best practices:
- Centralize Copilot usage in a single module or provider.
- Keep secrets and access tokens in config, not source code.
- Add tests that cover Copilot entry points.
For enforcement details, see [Copilot SDK usage](/validation/copilot-usage).

View File

@ -10,16 +10,18 @@
"colors": {
"primary": "#FF5A36"
},
"topbarLinks": [
{
"name": "GitHub",
"url": "https://github.com/clawdbot/clawdbot"
},
{
"name": "Releases",
"url": "https://github.com/clawdbot/clawdbot/releases"
}
],
"navbar": {
"links": [
{
"label": "GitHub",
"href": "https://github.com/clawdbot/clawdbot"
},
{
"label": "Releases",
"href": "https://github.com/clawdbot/clawdbot/releases"
}
]
},
"redirects": [
{
"source": "/cron",
@ -823,6 +825,13 @@
"help/faq"
]
},
{
"group": "Developer",
"pages": [
"developer-guide",
"validation/copilot-usage"
]
},
{
"group": "Install & Updates",
"pages": [

91
docs/plans/plan.md Normal file
View File

@ -0,0 +1,91 @@
# Copilot SDK Verification Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Verify and enforce the use of the official @github/copilot-sdk across the codebase.
**Architecture:** Scan the repository for Copilot integration points, audit dependencies, identify non-SDK usage, and implement an automated enforcement mechanism (lint or codemod). Validate via tests and CI, and update docs as needed.
**Tech Stack:** Node.js, TypeScript, ESLint/codemod, ripgrep, git, CI.
### Task 1: Audit Copilot usage
**Files:**
- Create: (none)
- Modify: (none)
- Test: (none)
**Step 1: Scan repository for Copilot references and SDK usage.**
```bash
rg -n "@github/copilot-sdk|@github/copilot" -S
```
Expected: list of files where Copilot is imported or configured. If none found, note absence and plan to add coverage.
**Step 2: Record findings.**
Document all occurrences in a summary file under `docs/validation/copilot-usage.md` including file paths and how Copilot is integrated.
### Task 2: Decide enforcement strategy
**Files:**
- Create: `tools/copilot-enforcement/README.md`
- Modify: `package.json` (optional) to add a lint script if desired
- Test: `tests/copilot-usage.test.ts` or equivalent
**Option A (Recommended): ESLint rule to require @github/copilot-sdk**
**Step 1:** Implement a custom ESLint rule that flags any import or require of Copilot outside the official SDK path, and auto-fix where possible.
**Step 2:** Wire rule into ESLint config and run tests.
**Step 3:** Add a test file asserting the rule flags non-SDK usage and allows SDK usage.
**Option B:** Codemod using jscodeshift to rewrite non-SDK usage to a safe pattern or emit a warning.
### Task 3: Add tests and run verification
**Files:**
- Test: `tests/copilot-usage.test.ts` (for ESLint rule) or `tests/codemod/copilot-usage.js` (for codemod)
**Step 1:** Create test cases that cover:
- Correct usage with @github/copilot-sdk
- Incorrect usage with other Copilot integration patterns
**Step 2:** Run tests and ensure they pass/fail as expected.
**Step 3:** Integrate with CI so guards run on PRs.
### Task 4: Documentation and guidance
**Files:**
- Update: `docs/developer-guide.md` with Copilot SDK usage best practices
- Create: `docs/validation/copilot-usage.md` with rules and examples
### Task 5: Execute, verify, and commit
**Step 1:** Run full test suite locally.
**Step 2:** If all tests pass, commit changes with a concise message.
**Step 3:** Push and open a PR.
## Execution Handoff
After saving the plan, offer execution choice:
**"Plan complete and saved to `docs/plans/<filename>.md`. Two execution options:**
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**
**If Subagent-Driven chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development
- Stay in this session
- Fresh subagent per task + code review
**If Parallel Session chosen:**
- Guide them to open new session in worktree
- **REQUIRED SUB-SKILL:** superpowers:executing-plans
- Your operational mode has changed from plan to build.
- You are no longer in read-only mode. You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.

View File

@ -1,8 +1,8 @@
---
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
summary: "Use GitHub Copilot via the official Copilot CLI SDK"
read_when:
- You want to use GitHub Copilot as a model provider
- You need the `clawdbot models auth login-github-copilot` flow
- You need the Copilot CLI + SDK flow
---
# Github Copilot
@ -10,35 +10,25 @@ read_when:
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Clawdbot can use Copilot as a model
provider in two different ways.
provider via the official Copilot CLI SDK.
## Two ways to use Copilot in Clawdbot
## Use Copilot in Clawdbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
the proxys `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
Clawdbot integrates with GitHub Copilot through the official Copilot CLI SDK.
This requires the Copilot CLI to be installed and authenticated on the gateway
host.
## CLI setup
```bash
clawdbot models auth login-github-copilot
copilot auth login
```
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
Then validate Copilot in Clawdbot:
```bash
clawdbot models auth login-github-copilot
```
### Optional flags
@ -66,5 +56,4 @@ clawdbot models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
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 Clawdbot runs.
- Copilot CLI must stay available on the gateway host.

View File

@ -0,0 +1,38 @@
---
title: Copilot SDK usage
---
Clawdbot integrations that use GitHub Copilot must rely on the official `@github/copilot-sdk` package. This keeps the integration consistent and simplifies maintenance.
## Requirements
- Use `@github/copilot-sdk` for all Copilot API integrations.
- Do not import or depend on other `@github/copilot*` packages.
- Keep Copilot usage in a dedicated module so usage can be audited easily.
## Automated enforcement
The repo ships a validation script that scans for non-SDK Copilot usage.
- Run `pnpm copilot:check` to validate the tree.
- The check runs in CI via the Vitest guard in the test suite.
## Current status
Clawdbot integrates with GitHub Copilot via the official `@github/copilot-sdk` package.
## Examples
Allowed:
```
import { createClient } from "@github/copilot-sdk";
```
Disallowed:
```
import { CopilotClient } from "@github/copilot";
```
For guidance on integrating Copilot, see the [Developer guide](/developer-guide).

View File

@ -1,24 +1,3 @@
# Copilot Proxy (Clawdbot plugin)
# Copilot Proxy (removed)
Provider plugin for the **Copilot Proxy** VS Code extension.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable copilot-proxy
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider copilot-proxy --set-default
```
## Notes
- Copilot Proxy must be running in VS Code.
- Base URL must include `/v1`.
This plugin has been deprecated. Use the official Copilot SDK integration instead.

View File

@ -1,8 +1,6 @@
{
"id": "copilot-proxy",
"providers": [
"copilot-proxy"
],
"providers": [],
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@ -1,141 +1,9 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_MODEL_IDS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5-mini",
"claude-opus-4.5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3-pro",
"gemini-3-flash",
"grok-code-fast-1",
] as const;
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_BASE_URL;
let normalized = trimmed;
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
return normalized;
}
function validateBaseUrl(value: string): string | undefined {
const normalized = normalizeBaseUrl(value);
try {
new URL(normalized);
} catch {
return "Enter a valid URL";
}
return undefined;
}
function parseModelIds(input: string): string[] {
const parsed = input
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
return Array.from(new Set(parsed));
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
api: "openai-completions",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: "copilot-proxy",
label: "Copilot Proxy",
docsPath: "/providers/models",
auth: [
{
id: "local",
label: "Local proxy",
hint: "Configure base URL + models for the Copilot Proxy server",
kind: "custom",
run: async (ctx) => {
const baseUrlInput = await ctx.prompter.text({
message: "Copilot Proxy base URL",
initialValue: DEFAULT_BASE_URL,
validate: validateBaseUrl,
});
const modelInput = await ctx.prompter.text({
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
const modelIds = parseModelIds(modelInput);
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
return {
profiles: [
{
profileId: "copilot-proxy:local",
credential: {
type: "token",
provider: "copilot-proxy",
token: DEFAULT_API_KEY,
},
},
],
configPatch: {
models: {
providers: {
"copilot-proxy": {
baseUrl,
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
),
},
},
},
defaultModel: defaultModelRef,
notes: [
"Start the Copilot Proxy VS Code extension before using these models.",
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
],
};
},
},
],
});
name: "Copilot Proxy (removed)",
description: "Deprecated: use the official Copilot SDK integration instead.",
register() {
// Intentionally empty: copilot-proxy support has been removed.
},
};

View File

@ -2,7 +2,9 @@
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.25",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"description": "Deprecated: copilot-proxy integration removed in favor of the Copilot SDK.",
"private": true,
"deprecated": "Removed in favor of the official Copilot SDK integration.",
"clawdbot": {
"extensions": [
"./index.ts"

View File

@ -107,6 +107,7 @@
"mac:restart": "bash scripts/restart-mac.sh",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:open": "open dist/Clawdbot.app",
"copilot:check": "node --import tsx tools/copilot-enforcement/check-copilot-usage.ts",
"lint": "oxlint --type-aware src test",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:all": "pnpm lint && pnpm lint:swift",
@ -156,6 +157,7 @@
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@github/copilot-sdk": "^0.1.18",
"@homebridge/ciao": "^1.3.4",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",

173
pnpm-lock.yaml generated
View File

@ -25,6 +25,9 @@ importers:
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
'@github/copilot-sdk':
specifier: ^0.1.18
version: 0.1.18
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.39.3)
@ -357,8 +360,8 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
specifier: '>=2026.1.25'
version: link:../..
specifier: '>=2026.1.24'
version: 2026.1.24(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
extensions/memory-lancedb:
dependencies:
@ -982,6 +985,50 @@ packages:
'@eshaz/web-worker@1.2.2':
resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==}
'@github/copilot-darwin-arm64@0.0.394':
resolution: {integrity: sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@github/copilot-darwin-x64@0.0.394':
resolution: {integrity: sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==}
cpu: [x64]
os: [darwin]
hasBin: true
'@github/copilot-linux-arm64@0.0.394':
resolution: {integrity: sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==}
cpu: [arm64]
os: [linux]
hasBin: true
'@github/copilot-linux-x64@0.0.394':
resolution: {integrity: sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==}
cpu: [x64]
os: [linux]
hasBin: true
'@github/copilot-sdk@0.1.18':
resolution: {integrity: sha512-BjHJihKOQXjd05mlG+aJ0fo/56q72CfbSHpTJvoSnQMJQ1oG2ztsYoQshFMCJYap/q0FtsYnIMu5x2v8nIzN8Q==}
engines: {node: '>=18.0.0'}
'@github/copilot-win32-arm64@0.0.394':
resolution: {integrity: sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==}
cpu: [arm64]
os: [win32]
hasBin: true
'@github/copilot-win32-x64@0.0.394':
resolution: {integrity: sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==}
cpu: [x64]
os: [win32]
hasBin: true
'@github/copilot@0.0.394':
resolution: {integrity: sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==}
hasBin: true
'@glideapps/ts-necessities@2.2.3':
resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==}
@ -3124,6 +3171,11 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clawdbot@2026.1.24:
resolution: {integrity: sha512-foszbNXzk743kQBx2Nfc2KNlStZyBAVAYLQJ+KaONh009r8oB1a74kQ8wTnKX+SDNaQ9MvaE9tOisVUi+H3F+Q==}
engines: {node: '>=22.12.0'}
hasBin: true
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@ -5355,6 +5407,10 @@ packages:
jsdom:
optional: true
vscode-jsonrpc@8.2.1:
resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
engines: {node: '>=14.0.0'}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@ -6496,6 +6552,39 @@ snapshots:
'@eshaz/web-worker@1.2.2':
optional: true
'@github/copilot-darwin-arm64@0.0.394':
optional: true
'@github/copilot-darwin-x64@0.0.394':
optional: true
'@github/copilot-linux-arm64@0.0.394':
optional: true
'@github/copilot-linux-x64@0.0.394':
optional: true
'@github/copilot-sdk@0.1.18':
dependencies:
'@github/copilot': 0.0.394
vscode-jsonrpc: 8.2.1
zod: 4.3.6
'@github/copilot-win32-arm64@0.0.394':
optional: true
'@github/copilot-win32-x64@0.0.394':
optional: true
'@github/copilot@0.0.394':
optionalDependencies:
'@github/copilot-darwin-arm64': 0.0.394
'@github/copilot-darwin-x64': 0.0.394
'@github/copilot-linux-arm64': 0.0.394
'@github/copilot-linux-x64': 0.0.394
'@github/copilot-win32-arm64': 0.0.394
'@github/copilot-win32-x64': 0.0.394
'@glideapps/ts-necessities@2.2.3': {}
'@google/genai@1.34.0':
@ -8862,6 +8951,84 @@ snapshots:
dependencies:
clsx: 2.1.1
clawdbot@2026.1.24(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
dependencies:
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.975.0
'@buape/carbon': 0.14.0(hono@4.11.4)
'@clack/prompts': 0.11.0
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
'@homebridge/ciao': 1.3.4
'@line/bot-sdk': 10.6.0
'@lydell/node-pty': 1.2.0-beta.3
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@mozilla/readability': 0.6.0
'@sinclair/typebox': 0.34.47
'@slack/bolt': 4.6.0(@types/express@5.0.6)
'@slack/web-api': 7.13.0
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
ajv: 8.17.1
body-parser: 2.2.2
chalk: 5.6.2
chokidar: 5.0.0
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
cli-highlight: 2.1.11
commander: 14.0.2
croner: 9.1.0
detect-libc: 2.1.2
discord-api-types: 0.38.37
dotenv: 17.2.3
express: 5.2.1
file-type: 21.3.0
grammy: 1.39.3
hono: 4.11.4
jiti: 2.6.1
json5: 2.2.3
jszip: 3.10.1
linkedom: 0.18.12
long: 5.3.2
markdown-it: 14.1.0
node-edge-tts: 1.2.9
osc-progress: 0.3.0
pdfjs-dist: 5.4.530
playwright-core: 1.58.0
proper-lockfile: 4.1.2
qrcode-terminal: 0.12.0
sharp: 0.34.5
sqlite-vec: 0.1.7-alpha.2
tar: 7.5.4
tslog: 4.10.2
undici: 7.19.0
ws: 8.19.0
yaml: 2.8.2
zod: 4.3.6
optionalDependencies:
'@napi-rs/canvas': 0.1.88
node-llama-cpp: 3.15.0(typescript@5.9.3)
transitivePeerDependencies:
- '@discordjs/opus'
- '@modelcontextprotocol/sdk'
- '@types/express'
- audio-decode
- aws-crt
- bufferutil
- canvas
- debug
- devtools-protocol
- encoding
- ffmpeg-static
- jimp
- link-preview-js
- node-opus
- opusscript
- supports-color
- typescript
- utf-8-validate
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@ -11374,6 +11541,8 @@ snapshots:
- tsx
- yaml
vscode-jsonrpc@8.2.1: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}

View File

@ -0,0 +1,171 @@
import path from "node:path";
import { CopilotClient } from "@github/copilot-sdk";
import type { ImageContent } from "@mariozechner/pi-ai";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } 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 { writeCliImages } from "./cli-runner/helpers.js";
import { buildSystemPrompt } from "./cli-runner/helpers.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
const log = createSubsystemLogger("agent/copilot-sdk");
export async function runCopilotSdkAgent(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
workspaceDir: string;
config?: ClawdbotConfig;
prompt: string;
provider: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
cliSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
const modelId = (params.model ?? "gpt-4.1").trim() || "gpt-4.1";
const modelDisplay = `${params.provider}/${modelId}`;
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 resolveClawdbotDocsPath({
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,
});
let cleanupImages: (() => Promise<void>) | undefined;
let attachments: Array<{ type: "file"; path: string; displayName?: string }> | undefined;
if (params.images && params.images.length > 0) {
const imagePayload = await writeCliImages(params.images);
cleanupImages = imagePayload.cleanup;
attachments = imagePayload.paths.map((imagePath) => ({
type: "file",
path: imagePath,
displayName: path.basename(imagePath),
}));
}
const client = new CopilotClient();
try {
await client.start();
let session:
| Awaited<ReturnType<CopilotClient["createSession"]>>
| Awaited<ReturnType<CopilotClient["resumeSession"]>>;
if (params.cliSessionId) {
try {
session = await client.resumeSession(params.cliSessionId);
} catch (err) {
log.warn(`copilot-sdk resume failed, starting new session: ${String(err)}`);
session = await client.createSession({
model: modelId,
systemMessage: { content: systemPrompt },
});
}
} else {
session = await client.createSession({
model: modelId,
systemMessage: { content: systemPrompt },
});
}
const messageOptions: { prompt: string; attachments?: typeof attachments } = {
prompt: params.prompt,
};
if (attachments && attachments.length > 0) {
messageOptions.attachments = attachments;
}
const response = await session.sendAndWait(messageOptions, params.timeoutMs);
const text = response?.data?.content?.trim();
if (!text) {
throw new Error("Copilot SDK returned no response.");
}
return {
payloads: [{ text }],
meta: {
durationMs: Date.now() - started,
agentMeta: {
sessionId: session.sessionId,
provider: params.provider,
model: modelId,
},
},
} satisfies EmbeddedPiRunResult;
} catch (err) {
if (err instanceof FailoverError) throw err;
const message = err instanceof Error ? err.message : String(err);
if (isFailoverErrorMessage(message)) {
const reason = classifyFailoverReason(message) ?? "unknown";
const status = resolveFailoverStatus(reason);
throw new FailoverError(message, {
reason,
provider: params.provider,
model: modelId,
status,
});
}
throw err;
} finally {
if (cleanupImages) {
await cleanupImages();
}
await client.stop();
}
}

View File

@ -34,12 +34,17 @@ export function normalizeProviderId(provider: string): string {
export function isCliProvider(provider: string, cfg?: ClawdbotConfig): boolean {
const normalized = normalizeProviderId(provider);
if (normalized === "github-copilot") return true;
if (normalized === "claude-cli") return true;
if (normalized === "codex-cli") return true;
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized);
}
export function isCopilotSdkProvider(provider: string): boolean {
return normalizeProviderId(provider) === "github-copilot";
}
function normalizeAnthropicModelId(model: string): string {
const trimmed = model.trim();
if (!trimmed) return trimmed;

View File

@ -427,7 +427,11 @@ export async function resolveImplicitCopilotProvider(params: {
}): Promise<ProviderConfig | null> {
const env = params.env ?? process.env;
const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0;
const profileIds = listProfilesForProvider(authStore, "github-copilot");
const hasProfile = profileIds.some((profileId) => {
const profile = authStore.profiles[profileId];
return profile?.type === "token" && Boolean(profile.token?.trim());
});
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
const githubToken = (envToken ?? "").trim();
@ -437,7 +441,7 @@ export async function resolveImplicitCopilotProvider(params: {
if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profileId = profileIds[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;

View File

@ -2,9 +2,10 @@ import crypto from "node:crypto";
import fs from "node:fs";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runCliAgent } from "../../agents/cli-runner.js";
import { runCopilotSdkAgent } from "../../agents/copilot-sdk-runner.js";
import { getCliSessionId } from "../../agents/cli-session.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { isCliProvider, isCopilotSdkProvider } from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
isCompactionFailureError,
@ -150,6 +151,60 @@ export async function runAgentTurnWithFallback(params: {
thinkLevel: params.followupRun.run.thinkLevel,
});
if (isCopilotSdkProvider(provider)) {
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt,
},
});
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
return runCopilotSdkAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
config: params.followupRun.run.config,
prompt: params.commandBody,
provider,
model,
thinkLevel: params.followupRun.run.thinkLevel,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
images: params.opts?.images,
})
.then((result) => {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
},
});
return result;
})
.catch((err) => {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
},
});
throw err;
});
}
if (isCliProvider(provider, params.followupRun.run.config)) {
const startedAt = Date.now();
emitAgentEvent({

View File

@ -82,8 +82,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
{
value: "copilot",
label: "Copilot",
hint: "GitHub + local proxy",
choices: ["github-copilot", "copilot-proxy"],
hint: "GitHub Copilot CLI",
choices: ["github-copilot"],
},
{
value: "openrouter",
@ -204,8 +204,8 @@ export function buildAuthChoiceOptions(params: {
});
options.push({
value: "github-copilot",
label: "GitHub Copilot (GitHub device login)",
hint: "Uses GitHub device flow",
label: "GitHub Copilot (Copilot CLI)",
hint: "Uses the official Copilot CLI + SDK",
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({
@ -220,11 +220,6 @@ export function buildAuthChoiceOptions(params: {
});
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
options.push({ value: "qwen-portal", label: "Qwen OAuth" });
options.push({
value: "copilot-proxy",
label: "Copilot Proxy (local)",
hint: "Local proxy for VS Code Copilot models",
});
options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({

View File

@ -1,14 +1,2 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceCopilotProxy(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "copilot-proxy",
pluginId: "copilot-proxy",
providerId: "copilot-proxy",
methodId: "local",
label: "Copilot Proxy",
});
}
// Deprecated: copilot-proxy integration removed in favor of the official Copilot SDK.
export {};

View File

@ -11,7 +11,7 @@ export async function applyAuthChoiceGitHubCopilot(
await params.prompter.note(
[
"This will open a GitHub device login to authorize Copilot.",
"This will open Copilot CLI login to authorize GitHub Copilot.",
"Requires an active GitHub Copilot subscription.",
].join("\n"),
"GitHub Copilot",

View File

@ -3,7 +3,6 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
@ -44,7 +43,6 @@ export async function applyAuthChoice(
applyAuthChoiceGitHubCopilot,
applyAuthChoiceGoogleAntigravity,
applyAuthChoiceGoogleGeminiCli,
applyAuthChoiceCopilotProxy,
applyAuthChoiceQwenPortal,
];

View File

@ -21,7 +21,6 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"synthetic-api-key": "synthetic",
"venice-api-key": "venice",
"github-copilot": "github-copilot",
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax",
"minimax-api": "minimax",
"minimax-api-lightning": "minimax",

View File

@ -29,7 +29,6 @@ export type AuthChoice =
| "minimax-api-lightning"
| "opencode-zen"
| "github-copilot"
| "copilot-proxy"
| "qwen-portal"
| "skip";
export type GatewayAuthChoice = "off" | "token" | "password";

View File

@ -32,7 +32,6 @@ const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
{ pluginId: "google-antigravity-auth", providerId: "google-antigravity" },
{ pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" },
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
];
function isRecord(value: unknown): value is Record<string, unknown> {

View File

@ -328,11 +328,14 @@ describe("runHeartbeatOnce", () => {
it("uses the last non-empty payload for delivery", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
@ -455,12 +458,15 @@ describe("runHeartbeatOnce", () => {
it("runs heartbeats in the explicit session key when configured", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
const groupId = "120363401234567890@g.us";
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: {
every: "5m",
target: "last",
@ -592,11 +598,14 @@ describe("runHeartbeatOnce", () => {
it("can include reasoning payloads when enabled", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@ -663,11 +672,14 @@ describe("runHeartbeatOnce", () => {
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: {
every: "5m",
target: "whatsapp",
@ -733,11 +745,13 @@ describe("runHeartbeatOnce", () => {
it("loads the default agent session from templated stores", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "5m" } },
defaults: { workspace: workspaceDir, heartbeat: { every: "5m" } },
list: [{ id: "work", default: true }],
},
channels: { whatsapp: { allowFrom: ["*"] } },

View File

@ -1,119 +1,15 @@
import { CopilotClient } from "@github/copilot-sdk";
import { intro, note, outro, spinner } from "@clack/prompts";
import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js";
import { updateConfig } from "../commands/models/shared.js";
import { applyAuthProfileConfig } from "../commands/onboard-auth.js";
import { logConfigUpdated } from "../config/logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
type DeviceCodeResponse = {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
};
type DeviceTokenResponse =
| {
access_token: string;
token_type: string;
scope?: string;
}
| {
error: string;
error_description?: string;
error_uri?: string;
};
function parseJsonResponse<T>(value: unknown): T {
if (!value || typeof value !== "object") {
throw new Error("Unexpected response from GitHub");
}
return value as T;
}
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
const body = new URLSearchParams({
client_id: CLIENT_ID,
scope: params.scope,
});
const res = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!res.ok) {
throw new Error(`GitHub device code failed: HTTP ${res.status}`);
}
const json = parseJsonResponse<DeviceCodeResponse>(await res.json());
if (!json.device_code || !json.user_code || !json.verification_uri) {
throw new Error("GitHub device code response missing fields");
}
return json;
}
async function pollForAccessToken(params: {
deviceCode: string;
intervalMs: number;
expiresAt: number;
}): Promise<string> {
const bodyBase = new URLSearchParams({
client_id: CLIENT_ID,
device_code: params.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
while (Date.now() < params.expiresAt) {
const res = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: bodyBase,
});
if (!res.ok) {
throw new Error(`GitHub device token failed: HTTP ${res.status}`);
}
const json = parseJsonResponse<DeviceTokenResponse>(await res.json());
if ("access_token" in json && typeof json.access_token === "string") {
return json.access_token;
}
const err = "error" in json ? json.error : "unknown";
if (err === "authorization_pending") {
await new Promise((r) => setTimeout(r, params.intervalMs));
continue;
}
if (err === "slow_down") {
await new Promise((r) => setTimeout(r, params.intervalMs + 2000));
continue;
}
if (err === "expired_token") {
throw new Error("GitHub device code expired; run login again");
}
if (err === "access_denied") {
throw new Error("GitHub login cancelled");
}
throw new Error(`GitHub device flow error: ${err}`);
}
throw new Error("GitHub device code expired; run login again");
}
const COPILOT_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
export async function githubCopilotLoginCommand(
opts: { profileId?: string; yes?: boolean },
@ -137,36 +33,36 @@ export async function githubCopilotLoginCommand(
);
}
const spin = spinner();
spin.start("Requesting device code from GitHub...");
const device = await requestDeviceCode({ scope: "read:user" });
spin.stop("Device code ready");
note(
[`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"),
stylePromptTitle("Authorize"),
);
const expiresAt = Date.now() + device.expires_in * 1000;
const intervalMs = Math.max(1000, device.interval * 1000);
const polling = spinner();
polling.start("Waiting for GitHub authorization...");
const accessToken = await pollForAccessToken({
deviceCode: device.device_code,
intervalMs,
expiresAt,
const loginSpin = spinner();
loginSpin.start("Starting Copilot CLI login...");
const loginResult = await runCommandWithTimeout(["copilot", "auth", "login"], {
timeoutMs: COPILOT_LOGIN_TIMEOUT_MS,
});
polling.stop("GitHub access token acquired");
loginSpin.stop("Copilot CLI login complete");
if (loginResult.code !== 0) {
const message =
loginResult.stderr.trim() || loginResult.stdout.trim() || "Copilot login failed";
throw new Error(message);
}
const clientSpin = spinner();
clientSpin.start("Validating Copilot SDK connection...");
const client = new CopilotClient();
try {
await client.start();
await client.ping("clawdbot");
} finally {
await client.stop();
}
clientSpin.stop("Copilot SDK ready");
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider: "github-copilot",
token: accessToken,
// GitHub device flow token doesn't reliably include expiry here.
// Leave expires unset; we'll exchange into Copilot token plus expiry later.
token: "",
},
});
@ -179,7 +75,7 @@ export async function githubCopilotLoginCommand(
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (github-copilot/token)`);
runtime.log(`Auth profile: ${profileId} (github-copilot/cli)`);
outro("Done");
}

View File

@ -0,0 +1,13 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { scanCopilotUsage } from "../tools/copilot-enforcement/check-copilot-usage";
describe("Copilot SDK usage", () => {
it("allows only @github/copilot-sdk", async () => {
const repoRoot = path.resolve(process.cwd());
const findings = await scanCopilotUsage(repoRoot);
expect(findings).toEqual([]);
});
});

View File

@ -0,0 +1,18 @@
# Copilot SDK enforcement
This tool enforces that the codebase only references the official `@github/copilot-sdk` package.
## What it checks
- Imports or requires of `@github/copilot*` packages outside `@github/copilot-sdk`
- `package.json` dependency entries that reference `@github/copilot*` packages other than `@github/copilot-sdk`
## Usage
Run the check from the repo root:
```
pnpm copilot:check
```
The script exits with a non-zero status if any non-SDK usage is found.

View File

@ -0,0 +1,186 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type FindingKind = "import" | "dependency";
type Finding = {
file: string;
kind: FindingKind;
specifier: string;
line: number | null;
};
const CODE_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
]);
const EXCLUDED_DIRS = new Set([
".git",
"node_modules",
"dist",
"vendor",
"coverage",
".turbo",
".next",
"build",
]);
const COPILOT_IMPORT_PATTERN = /(?:from\s+|require\(|import\()\s*["'](@github\/copilot[^"']*)["']/g;
const COPILOT_SIDE_EFFECT_PATTERN = /import\s*["'](@github\/copilot[^"']*)["']/g;
export async function scanCopilotUsage(rootDir: string): Promise<Finding[]> {
const findings: Finding[] = [];
await walkDirectory(rootDir, async (filePath) => {
const relativePath = path.relative(rootDir, filePath);
if (path.basename(filePath) === "package.json") {
const packageFindings = await scanPackageJson(filePath, relativePath);
findings.push(...packageFindings);
return;
}
if (!CODE_EXTENSIONS.has(path.extname(filePath))) {
return;
}
const content = await readFile(filePath, "utf8");
findings.push(...scanSourceFile(content, relativePath));
});
return findings;
}
async function walkDirectory(
dirPath: string,
onFile: (filePath: string) => Promise<void>
): Promise<void> {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) {
continue;
}
await walkDirectory(path.join(dirPath, entry.name), onFile);
continue;
}
if (entry.isFile()) {
await onFile(path.join(dirPath, entry.name));
}
}
}
function scanSourceFile(content: string, relativePath: string): Finding[] {
const findings: Finding[] = [];
for (const pattern of [COPILOT_IMPORT_PATTERN, COPILOT_SIDE_EFFECT_PATTERN]) {
for (const match of content.matchAll(pattern)) {
const specifier = match[1];
if (specifier === "@github/copilot-sdk") {
continue;
}
if (specifier.startsWith("@github/copilot")) {
findings.push({
file: relativePath,
kind: "import",
specifier,
line: getLineNumber(content, match.index ?? 0),
});
}
}
}
return findings;
}
async function scanPackageJson(
filePath: string,
relativePath: string
): Promise<Finding[]> {
const findings: Finding[] = [];
const content = await readFile(filePath, "utf8");
try {
const json = JSON.parse(content) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const dependencyBlocks = [
json.dependencies,
json.devDependencies,
json.peerDependencies,
json.optionalDependencies,
];
for (const block of dependencyBlocks) {
if (!block) {
continue;
}
for (const name of Object.keys(block)) {
if (name === "@github/copilot-sdk") {
continue;
}
if (name.startsWith("@github/copilot")) {
findings.push({
file: relativePath,
kind: "dependency",
specifier: name,
line: null,
});
}
}
}
} catch {
return findings;
}
return findings;
}
function getLineNumber(content: string, index: number): number {
return content.slice(0, index).split("\n").length;
}
function formatFindings(findings: Finding[]): string {
return findings
.map((finding) => {
const line = finding.line ? `:${finding.line}` : "";
return `${finding.file}${line} - ${finding.kind} ${finding.specifier}`;
})
.join("\n");
}
async function runCli(): Promise<void> {
const rootDir = process.cwd();
const findings = await scanCopilotUsage(rootDir);
if (findings.length === 0) {
console.log("Copilot SDK check passed.");
return;
}
console.error("Non-SDK Copilot usage detected:\n" + formatFindings(findings));
process.exitCode = 1;
}
const isCli =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
runCli();
}