Skip to content

Commit

Permalink
feat: add GitHub Copilot Chat support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
egoist committed Nov 18, 2024
1 parent 203a0fd commit 2b7c78e
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 34 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 1 addition & 20 deletions src/ai-command.ts
Original file line number Diff line number Diff line change
@@ -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<string, AICommand> = {}
Expand All @@ -20,25 +20,6 @@ export function getAllCommands(config: Config) {
return [...Object.values(commands)]
}

async function runCommand(command: string) {
return new Promise<string>((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<string, AICommandVariable> | undefined,
Expand Down
28 changes: 27 additions & 1 deletion src/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
}
Expand Down Expand Up @@ -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")
Expand All @@ -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
}
21 changes: 10 additions & 11 deletions src/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function ask(
stream?: boolean
reply?: boolean
breakdown?: boolean
},
}
) {
if (!prompt) {
throw new CliError("please provide a prompt")
Expand All @@ -52,8 +52,8 @@ export async function ask(
modelId === "select"
? true
: modelId === "ollama" || modelId.startsWith("ollama-")
? "required"
: false,
? "required"
: false
)

if (
Expand All @@ -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`"
)
}

Expand All @@ -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 {
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
#!/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"
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({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading

0 comments on commit 2b7c78e

Please sign in to comment.