From 2b7c78e8f2ddec509ed52ade168986ace01d7b55 Mon Sep 17 00:00:00 2001 From: EGOIST <0x142857@gmail.com> Date: Mon, 18 Nov 2024 17:00:28 +0800 Subject: [PATCH] feat: add GitHub Copilot Chat support This release adds GitHub Copilot Chat integration, enabling users to interact with Copilot's powerful AI models directly from the command line. Key features: - Support for Copilot Chat models including GPT-4, OpenAI-O1, and Claude-3.5 - New commands `ask copilot-login` and `ask copilot-logout` for authentication - Secure token storage in the local config directory - Seamless integration with existing shell-ask workflows Technical changes: - Added Copilot authentication and token management - Refactored AI SDK initialization to support async operations - Enhanced error handling for API responses - Added colorette for improved CLI output formatting - Moved common utilities into a separate module To use Copilot Chat: 1. Run `ask copilot-login` to authenticate 2. Use any Copilot model with `-m copilot-*` flag 3. Use `ask copilot-logout` to remove authentication --- README.md | 1 + package.json | 1 + pnpm-lock.yaml | 8 +++ src/ai-command.ts | 21 +------- src/ai-sdk.ts | 28 ++++++++++- src/ask.ts | 21 ++++---- src/cli.ts | 46 ++++++++++++++++++ src/config.ts | 2 +- src/copilot.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++++ src/models.ts | 34 +++++++++++++ src/utils.ts | 22 ++++++++- 11 files changed, 271 insertions(+), 34 deletions(-) create mode 100644 src/copilot.ts diff --git a/README.md b/README.md index 6c5df7e..8d44dae 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ npm i -g shell-ask - [Ollama](https://ollama.com) - [Google Gemini](https://aistudio.google.com/) - [Groq](https://console.groq.com) +- GitHub Copilot Chat ## Configuration diff --git a/package.json b/package.json index dee7b33..422bc73 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@types/node": "22.9.0", "@types/prompts": "^2.4.9", "@types/update-notifier": "^6.0.8", + "colorette": "^2.0.20", "tsup": "^8.0.2", "typescript": "5.6.3", "zod-to-json-schema": "3.23.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abd7fd0..db2d6a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@types/update-notifier': specifier: ^6.0.8 version: 6.0.8 + colorette: + specifier: ^2.0.20 + version: 2.0.20 tsup: specifier: ^8.0.2 version: 8.0.2(postcss@8.4.38)(typescript@5.6.3) @@ -688,6 +691,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2096,6 +2102,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 diff --git a/src/ai-command.ts b/src/ai-command.ts index 95380d3..35679a8 100644 --- a/src/ai-command.ts +++ b/src/ai-command.ts @@ -1,8 +1,8 @@ -import { exec } from "node:child_process" import { AICommand, AICommandVariable, Config } from "./config" import { stdin } from "./tty" import prompts from "prompts" import { builtinCommands } from "./builtin-commands" +import { runCommand } from "./utils" export function getAllCommands(config: Config) { const commands: Record = {} @@ -20,25 +20,6 @@ export function getAllCommands(config: Config) { return [...Object.values(commands)] } -async function runCommand(command: string) { - return new Promise((resolve, reject) => { - const cmd = exec(command) - let output = "" - cmd.stdout?.on("data", (data) => { - output += data - }) - cmd.stderr?.on("data", (data) => { - output += data - }) - cmd.on("close", () => { - resolve(output) - }) - cmd.on("error", (error) => { - reject(error) - }) - }) -} - export async function getPrompt( prompt: string, variables: Record | undefined, diff --git a/src/ai-sdk.ts b/src/ai-sdk.ts index dd075eb..c0c0629 100644 --- a/src/ai-sdk.ts +++ b/src/ai-sdk.ts @@ -5,6 +5,7 @@ import { Config } from "./config" import { createAnthropic } from "@ai-sdk/anthropic" import { createGoogleGenerativeAI } from "@ai-sdk/google" import { CliError } from "./error" +import { copilot } from "./copilot" const missingConfigError = ( type: "openai" | "anthropic" | "gemini" | "groq" @@ -14,7 +15,7 @@ const missingConfigError = ( ) } -export const getSDKModel = (modelId: string, config: Config) => { +export const getSDKModel = async (modelId: string, config: Config) => { if (modelId.startsWith("ollama-")) { return createOllama() } @@ -57,6 +58,18 @@ export const getSDKModel = (modelId: string, config: Config) => { }) } + if (modelId.startsWith("copilot-")) { + const apiKey = await getCopilotApiKey() + return createOpenAI({ + apiKey, + baseURL: `https://api.githubcopilot.com`, + headers: { + "editor-version": "vscode/0.1.0", + "copilot-integration-id": "vscode-chat", + }, + }) + } + const apiKey = config.openai_api_key || process.env.OPENAI_API_KEY if (!apiKey) { throw missingConfigError("openai") @@ -69,3 +82,16 @@ export const getSDKModel = (modelId: string, config: Config) => { baseURL: apiUrl, }) } + +export const getCopilotApiKey = async () => { + const authToken = process.env.COPILOT_AUTH_TOKEN || copilot.loadAuthToken() + + if (!authToken) { + throw new CliError( + `failed to get auth token, please login with 'ask copilot-login' first` + ) + } + + const result = await copilot.getCopilotToken(authToken) + return result.token +} diff --git a/src/ask.ts b/src/ask.ts index ac1683f..a34a36f 100644 --- a/src/ask.ts +++ b/src/ask.ts @@ -32,7 +32,7 @@ export async function ask( stream?: boolean reply?: boolean breakdown?: boolean - }, + } ) { if (!prompt) { throw new CliError("please provide a prompt") @@ -52,8 +52,8 @@ export async function ask( modelId === "select" ? true : modelId === "ollama" || modelId.startsWith("ollama-") - ? "required" - : false, + ? "required" + : false ) if ( @@ -63,7 +63,7 @@ export async function ask( ) { if (process.platform === "win32" && !process.stdin.isTTY) { throw new CliError( - "Interactively selecting a model is not supported on Windows when using piped input. Consider directly specifying the model id instead, for example: `-m gpt-4o`", + "Interactively selecting a model is not supported on Windows when using piped input. Consider directly specifying the model id instead, for example: `-m gpt-4o`" ) } @@ -85,7 +85,7 @@ export async function ask( choices: models .filter( - (item) => modelId === "select" || item.id.startsWith(`${modelId}-`), + (item) => modelId === "select" || item.id.startsWith(`${modelId}-`) ) .map((item) => { return { @@ -106,17 +106,17 @@ export async function ask( debug(`Selected modelID: ${modelId}`) const matchedModel = models.find( - (m) => m.id === modelId || m.realId === modelId, + (m) => m.id === modelId || m.realId === modelId ) if (!matchedModel) { throw new CliError( `model not found: ${modelId}\n\navailable models: ${models .map((m) => m.id) - .join(", ")}`, + .join(", ")}` ) } const realModelId = matchedModel?.realId || modelId - const model = getSDKModel(modelId, config) + const model = await getSDKModel(modelId, config) debug("model", realModelId) @@ -134,7 +134,7 @@ export async function ask( remoteContents.length > 0 && "remote contents:", ...remoteContents.map( - (content) => `${content.url}:\n"""\n${content.content}\n"""`, + (content) => `${content.url}:\n"""\n${content.content}\n"""` ), ] .filter(notEmpty) @@ -197,8 +197,7 @@ export async function ask( const temperature = 0 const providerModelId = toProviderModelId(realModelId) - // @ts-expect-error Bun doesn't support TextDecoderStream - if (options.stream === false || typeof Bun !== "undefined") { + if (options.stream === false) { const result = await generateText({ model: model(providerModelId), messages, diff --git a/src/cli.ts b/src/cli.ts index d507e3f..8cc7eaa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import process from "node:process" import { cac, Command as CliCommand } from "cac" +import { bold, green, underline } from "colorette" import { getAllModels } from "./models" import updateNotifier from "update-notifier" import { ask } from "./ask" @@ -8,6 +9,8 @@ import { getAllCommands, getPrompt } from "./ai-command" import { readPipeInput } from "./tty" import { CliError } from "./error" import { loadConfig } from "./config" +import { copilot } from "./copilot" +import { APICallError } from "ai" if (typeof PKG_NAME === "string" && typeof PKG_VERSION === "string") { updateNotifier({ @@ -65,6 +68,47 @@ async function main() { } }) + cli.command("copilot-login").action(async () => { + const deviceCodeResult = await copilot.requestDeviceCode() + + console.log("First copy your one-time code:\n") + console.log(bold(green(deviceCodeResult.user_code))) + console.log() + console.log( + "Then visit this GitHub URL to authorize:", + underline(deviceCodeResult.verification_uri) + ) + + console.log() + console.log("Waiting for authentication...") + console.log(`Press ${bold("Enter")} to check the authentication status...`) + + const checkAuth = async () => { + const authResult = await copilot + .verifyAuth(deviceCodeResult) + .catch(() => null) + if (authResult) { + console.log("Authentication successful!") + copilot.saveAuthToken(authResult.access_token) + process.exit(0) + } else { + console.log("Authentication failed. Please try again.") + } + } + + // press Enter key to check auth + process.stdin.on("data", (data) => { + if (data.toString() === "\n") { + checkAuth() + } + }) + }) + + cli.command("copilot-logout").action(() => { + copilot.removeAuthToken() + console.log("Copilot auth token removed") + }) + const allCommands = getAllCommands(config) for (const command of allCommands) { const c = cli.command(command.command, command.description) @@ -133,6 +177,8 @@ async function main() { process.exitCode = 1 if (error instanceof CliError) { console.error(error.message) + } else if (error instanceof APICallError) { + console.log(error.responseBody) } else { throw error } diff --git a/src/config.ts b/src/config.ts index 95102a9..50ebd9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import os from "node:os" import path from "node:path" import { z } from "zod" -const configDirPath = path.join(os.homedir(), ".config", "shell-ask") +export const configDirPath = path.join(os.homedir(), ".config", "shell-ask") export const configFilePath = path.join(configDirPath, "config.json") const AICommandVariableSchema = z.union([ diff --git a/src/copilot.ts b/src/copilot.ts new file mode 100644 index 0000000..8504210 --- /dev/null +++ b/src/copilot.ts @@ -0,0 +1,121 @@ +import fs from "node:fs" +import path from "node:path" +import { z } from "zod" +import { configDirPath } from "./config" + +const DeviceCodeResponseSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + interval: z.number(), +}) + +type DeviceCodeResponse = z.infer + +const CopilotTokenResponseSchema = z.object({ + token: z.string(), + expires_at: z.number(), +}) + +type CopilotTokenResponse = z.infer + +const clientId = "Iv1.b507a08c87ecfe98" + +class Copilot { + copilotTokenResponseCache?: CopilotTokenResponse + + async requestDeviceCode() { + const res = await fetch( + `https://github.com/login/device/code?${new URLSearchParams({ + client_id: clientId, + })}`, + { + method: "POST", + headers: { + accept: "application/json", + }, + } + ) + + if (!res.ok) { + throw new Error(`Failed to request device code: ${await res.text()}`) + } + + const json = await res.json() + return DeviceCodeResponseSchema.parse(json) + } + + async verifyAuth({ device_code }: DeviceCodeResponse) { + const res = await fetch( + `https://github.com/login/oauth/access_token?${new URLSearchParams({ + client_id: clientId, + device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + })}`, + { + method: "POST", + headers: { + accept: "application/json", + }, + } + ) + + if (!res.ok) return null + + const json = await res.json() + return z.object({ access_token: z.string() }).parse(json) + } + + async getCopilotToken(authToken: string) { + if ( + this.copilotTokenResponseCache && + Date.now() < this.copilotTokenResponseCache.expires_at * 1000 + ) { + return this.copilotTokenResponseCache + } + + const res = await fetch( + `https://api.github.com/copilot_internal/v2/token`, + { + headers: { + authorization: `Bearer ${authToken}`, + accept: "application/json", + }, + } + ) + + if (!res.ok) { + throw new Error(`Failed to get token for chat: ${await res.text()}`) + } + + const json = await res.json() + const result = CopilotTokenResponseSchema.parse(json) + + this.copilotTokenResponseCache = result + return result + } + + saveAuthToken(token: string) { + fs.mkdirSync(configDirPath, { recursive: true }) + fs.writeFileSync(path.join(configDirPath, ".copilot-auth-token"), token) + } + + loadAuthToken() { + try { + return fs.readFileSync( + path.join(configDirPath, ".copilot-auth-token"), + "utf-8" + ) + } catch { + return null + } + } + + removeAuthToken() { + try { + fs.unlinkSync(path.join(configDirPath, ".copilot-auth-token")) + } catch {} + } +} + +export const copilot = new Copilot() diff --git a/src/models.ts b/src/models.ts index f8ddb32..afc8f85 100644 --- a/src/models.ts +++ b/src/models.ts @@ -20,6 +20,14 @@ export const MODEL_MAP: { id: "gpt-4o-mini", }, ], + openai: [ + { + id: "openai-o1-mini", + }, + { + id: "openai-o1-preview", + }, + ], claude: [ { id: "claude-3-haiku", @@ -81,6 +89,28 @@ export const MODEL_MAP: { realId: "groq-gemma-7b-it", }, ], + copilot: [ + { + id: "copilot-gpt-4", + realId: "gpt-4", + }, + { + id: "copilot-gpt-4o", + realId: "gpt-4o", + }, + { + id: "copilot-o1-mini", + realId: "o1-mini", + }, + { + id: "copilot-o1-preview", + realId: "o1-preview", + }, + { + id: "copilot-claude-3.5-sonnet", + realId: "claude-3.5-sonnet", + }, + ], } export const MODELS = Object.values(MODEL_MAP).flat() @@ -110,6 +140,10 @@ export function getCheapModelId(modelId: string) { if (modelId.startsWith("groq-")) return "groq-llama3-8b-8192" + if (modelId.startsWith("copilot-")) return "copilot-gpt-4o" + + if (modelId.startsWith("openai-")) return "gpt-4o-mini" + return modelId } diff --git a/src/utils.ts b/src/utils.ts index 1c7414d..8f26e85 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,6 @@ -import fs from "fs" +import fs from "node:fs" import glob from "fast-glob" +import { exec } from "node:child_process" export function notEmpty( value: TValue | null | undefined | "" | false @@ -23,3 +24,22 @@ export async function loadFiles( }) ) } + +export async function runCommand(command: string) { + return new Promise((resolve, reject) => { + const cmd = exec(command) + let output = "" + cmd.stdout?.on("data", (data) => { + output += data + }) + cmd.stderr?.on("data", (data) => { + output += data + }) + cmd.on("close", () => { + resolve(output) + }) + cmd.on("error", (error) => { + reject(error) + }) + }) +}