Skip to content

Commit 6bb07cb

Browse files
committed
feat: add Windows install, improve auth UX and paste support
- Add Windows installation support in install script - Fix Anthropic OAuth to auto-open browser automatically - Add clickable OAuth URLs with OSC 8 hyperlinks - Fix paste functionality in TUI (Ctrl+V and bracketed paste mode)
1 parent c7821e5 commit 6bb07cb

14 files changed

Lines changed: 305 additions & 7 deletions

File tree

changelog/CHANGELOG-2026-01-04.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Changelog - January 4, 2026
2+
3+
## Installation
4+
5+
### Windows Install Support
6+
7+
- Added Windows install support in install script
8+
- Fixed curl install tracking to follow redirects to Upstash
9+
10+
## Authentication
11+
12+
### Anthropic OAuth Browser Auto-Open
13+
14+
- Fixed OAuth link for Anthropic not opening browser automatically
15+
- Implemented internal Anthropic OAuth plugin with PKCE flow
16+
- Browser now auto-opens like Codex authentication flow
17+
- Added fallback clickable URLs when browser fails to open
18+
- Replaced external `opencode-anthropic-auth` plugin with internal implementation
19+
20+
### Clickable OAuth URLs
21+
22+
- Added `UI.hyperlink()` utility for OSC 8 terminal hyperlinks
23+
- OAuth URLs now clickable via Ctrl+Click in modern terminals
24+
- Bypassed `@clack/prompts` escape sequence stripping with direct console output
25+
- Updated Qwen and Amp OAuth flows to use clickable URLs
26+
27+
## TUI Improvements
28+
29+
### Enhanced Paste Support
30+
31+
- Added bracketed paste mode for better paste detection
32+
- Fixed Ctrl+V paste action (was disabled and only handled images)
33+
- Ctrl+V now supports both text and image pasting from clipboard
34+
- Improved paste reliability for large content
35+
- Paste now works via multiple methods:
36+
- Ctrl+V (system clipboard)
37+
- Native terminal paste (Shift+Insert, Cmd+V)
38+
- Bracketed paste mode
39+
40+
---
41+
42+
**Summary**: This release adds Windows installation support, fixes Anthropic OAuth to auto-open the browser, makes OAuth URLs clickable in terminals, and significantly improves paste functionality in the TUI. Authentication flows now provide a smoother experience with automatic browser opening and clickable fallback URLs.

packages/arctic/src/auth/amp-auth/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from "node:crypto"
33
import http from "node:http"
44
import { URL } from "node:url"
55
import { openBrowserUrl } from "../codex-oauth/auth/browser"
6+
import { UI } from "@/cli/ui"
67

78
const AMP_DEFAULT_URL = "https://ampcode.com"
89
const CALLBACK_PATH = "/auth/callback"
@@ -106,7 +107,7 @@ export const ArcticAmpAuth: Plugin = async (_input: PluginInput) => {
106107

107108
return {
108109
url: loginUrl,
109-
instructions: `If the callback fails, open ${manualUrl} and paste the access token.`,
110+
instructions: `If the callback fails, open ${UI.hyperlink(manualUrl, manualUrl)} and paste the access token.`,
110111
method: "auto",
111112
callback: async () => {
112113
try {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { refreshAccessToken, isTokenExpired, type TokenResult, type TokenResponse } from "./token"
2+
export { ArcticAnthropicAuth } from "./plugin"
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Plugin, PluginInput } from "@arctic-cli/plugin"
2+
import { openBrowserUrl } from "../codex-oauth/auth/browser"
3+
4+
export const ArcticAnthropicAuth: Plugin = async (_: PluginInput) => {
5+
return {
6+
auth: {
7+
provider: "anthropic",
8+
9+
methods: [
10+
{
11+
label: "Claude.ai Account (OAuth)",
12+
type: "oauth" as const,
13+
async authorize() {
14+
// Generate OAuth parameters
15+
const clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
16+
const redirectUri = "https://console.anthropic.com/oauth/code/callback"
17+
const scope = "org:create_api_key user:profile user:inference"
18+
19+
// Generate PKCE challenge
20+
const codeVerifier = generateCodeVerifier()
21+
const codeChallenge = await generateCodeChallenge(codeVerifier)
22+
const state = generateRandomString(64)
23+
24+
// Build authorization URL
25+
const params = new URLSearchParams({
26+
code: "true",
27+
client_id: clientId,
28+
response_type: "code",
29+
redirect_uri: redirectUri,
30+
scope,
31+
code_challenge: codeChallenge,
32+
code_challenge_method: "S256",
33+
state,
34+
})
35+
36+
const url = `https://claude.ai/oauth/authorize?${params.toString()}`
37+
38+
// Open browser automatically
39+
openBrowserUrl(url)
40+
41+
return {
42+
url,
43+
instructions:
44+
"Opening browser to authenticate with Claude.ai...\n\nIf the browser doesn't open automatically, visit the URL above.",
45+
method: "code" as const,
46+
async callback(code: string) {
47+
if (!code) {
48+
return { type: "failed" as const, error: "No authorization code provided" }
49+
}
50+
51+
try {
52+
// Exchange code for tokens
53+
const tokenResponse = await fetch("https://console.anthropic.com/v1/oauth/token", {
54+
method: "POST",
55+
headers: {
56+
"Content-Type": "application/json",
57+
},
58+
body: JSON.stringify({
59+
grant_type: "authorization_code",
60+
code,
61+
redirect_uri: redirectUri,
62+
code_verifier: codeVerifier,
63+
client_id: clientId,
64+
}),
65+
})
66+
67+
if (!tokenResponse.ok) {
68+
const errorText = await tokenResponse.text()
69+
console.error("[Anthropic OAuth] Token exchange failed:", tokenResponse.status, errorText)
70+
return { type: "failed" as const, error: `Token exchange failed: ${tokenResponse.status}` }
71+
}
72+
73+
const tokenData = await tokenResponse.json()
74+
75+
if (!tokenData.access_token) {
76+
console.error("[Anthropic OAuth] No access token in response:", tokenData)
77+
return { type: "failed" as const, error: "No access token received" }
78+
}
79+
80+
// Calculate expiration timestamp
81+
const expiresAt = Date.now() + (tokenData.expires_in ?? 3600) * 1000
82+
83+
return {
84+
type: "success" as const,
85+
access: tokenData.access_token,
86+
refresh: tokenData.refresh_token,
87+
expires: expiresAt,
88+
}
89+
} catch (error) {
90+
console.error("[Anthropic OAuth] Error during token exchange:", error)
91+
return {
92+
type: "failed" as const,
93+
error: error instanceof Error ? error.message : "Unknown error",
94+
}
95+
}
96+
},
97+
}
98+
},
99+
},
100+
],
101+
},
102+
}
103+
}
104+
105+
/**
106+
* Generate a random string for PKCE code verifier or state
107+
*/
108+
function generateRandomString(length: number): string {
109+
const array = new Uint8Array(length)
110+
crypto.getRandomValues(array)
111+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("")
112+
}
113+
114+
/**
115+
* Generate PKCE code verifier (43-128 characters)
116+
*/
117+
function generateCodeVerifier(): string {
118+
const array = new Uint8Array(32)
119+
crypto.getRandomValues(array)
120+
return base64UrlEncode(array)
121+
}
122+
123+
/**
124+
* Generate PKCE code challenge from verifier
125+
*/
126+
async function generateCodeChallenge(verifier: string): Promise<string> {
127+
const encoder = new TextEncoder()
128+
const data = encoder.encode(verifier)
129+
const hash = await crypto.subtle.digest("SHA-256", data)
130+
return base64UrlEncode(new Uint8Array(hash))
131+
}
132+
133+
/**
134+
* Base64 URL-safe encoding (no padding)
135+
*/
136+
function base64UrlEncode(buffer: Uint8Array): string {
137+
const base64 = btoa(String.fromCharCode(...buffer))
138+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
139+
}
140+
141+
export default ArcticAnthropicAuth

packages/arctic/src/auth/qwen-oauth/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Plugin, PluginInput } from "@arctic-cli/plugin"
22
import { openBrowserUrl } from "../codex-oauth/auth/browser"
33
import { Auth } from "../index"
44
import { ensureAuth, getApiBaseUrl, pollForToken, requestDeviceCode } from "./auth"
5+
import { UI } from "@/cli/ui"
56

67
export { ensureAuth, getApiBaseUrl } from "./auth"
78

@@ -53,7 +54,7 @@ export const ArcticQwenAuth: Plugin = async (_: PluginInput) => {
5354

5455
return {
5556
url,
56-
instructions: `Visit the URL in your browser and click the button to authorize.\n\nURL: ${url}`,
57+
instructions: `Visit the URL in your browser and click the button to authorize.\n\nURL: ${UI.hyperlink(url)}`,
5758
method: "auto" as const,
5859
async callback() {
5960
while (true) {

packages/arctic/src/cli/cmd/auth.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,16 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
101101
}
102102

103103
if (authorize.url) {
104-
prompts.log.info("Go to: " + authorize.url)
104+
// Use console.log directly because @clack/prompts strips escape sequences
105+
console.log("│")
106+
console.log("● Go to: " + UI.hyperlink(authorize.url))
105107
}
106108

107109
if (authorize.method === "auto") {
108110
if (authorize.instructions) {
109-
prompts.log.info(authorize.instructions)
111+
// Use console.log directly to preserve hyperlinks in instructions
112+
console.log("│")
113+
console.log("● " + authorize.instructions)
110114
}
111115
const spinner = prompts.spinner()
112116
spinner.start("Waiting for authorization...")

packages/arctic/src/cli/cmd/tui/app.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
116116
process.stdout.write("\x1b[>4;2m")
117117
// Enable focus tracking to handle terminal tab switches
118118
process.stdout.write("\x1b[?1004h")
119+
// Enable bracketed paste mode (wraps pasted text with \x1b[200~ and \x1b[201~ markers)
120+
// This prevents terminals from executing pasted commands and improves paste detection
121+
process.stdout.write("\x1b[?2004h")
119122

120123
// promise to prevent immediate exit
121124
return new Promise<void>(async (resolve) => {
@@ -126,6 +129,8 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
126129
process.stdout.write("\x1b[>4;0m")
127130
// Disable focus tracking
128131
process.stdout.write("\x1b[?1004l")
132+
// Disable bracketed paste mode
133+
process.stdout.write("\x1b[?2004l")
129134
await input.onExit?.()
130135
resolve()
131136
}
@@ -208,6 +213,8 @@ function App() {
208213
renderer.enableKittyKeyboard()
209214
process.stdout.write("\x1b[>4;1m")
210215
process.stdout.write("\x1b[>4;2m")
216+
// Re-enable bracketed paste mode
217+
process.stdout.write("\x1b[?2004h")
211218

212219
// Handle terminal focus events (e.g., switching tabs)
213220
// Focus-in events trigger a repaint to fix rendering issues

packages/arctic/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ export function Prompt(props: PromptProps) {
495495
{
496496
title: "Paste",
497497
value: "prompt.paste",
498-
disabled: true,
498+
disabled: false,
499499
keybind: "input_paste",
500500
category: "Prompt",
501501
onSelect: async () => {
@@ -506,6 +506,12 @@ export function Prompt(props: PromptProps) {
506506
mime: content.mime,
507507
content: content.data,
508508
})
509+
} else if (content?.mime === "text/plain") {
510+
// Paste text content from clipboard
511+
const text = content.data
512+
if (text) {
513+
input.insertText(text)
514+
}
509515
}
510516
},
511517
},

packages/arctic/src/cli/cmd/uninstall.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,22 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
199199
}
200200

201201
if (method === "curl" && targets.binary) {
202+
if (process.platform !== "win32") {
203+
spinner.start("Removing binary...")
204+
try {
205+
await fs.unlink(targets.binary)
206+
spinner.stop("Binary removed")
207+
208+
const binDir = path.dirname(targets.binary)
209+
if (binDir.includes(".arctic")) {
210+
await fs.rm(binDir, { recursive: true, force: true }).catch(() => {})
211+
}
212+
return
213+
} catch {
214+
spinner.stop("Failed to remove binary", 1)
215+
}
216+
}
217+
202218
UI.empty()
203219
prompts.log.message("To finish removing the binary, run:")
204220
prompts.log.info(` rm "${targets.binary}"`)

packages/arctic/src/cli/ui.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ export namespace UI {
2929
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
3030
}
3131

32+
/**
33+
* Makes a URL clickable in terminals that support OSC 8 hyperlinks.
34+
* Falls back to plain URL if not supported.
35+
*
36+
* @param url - The URL to make clickable
37+
* @param text - Optional display text (defaults to the URL itself)
38+
* @returns The formatted hyperlink
39+
*/
40+
export function hyperlink(url: string, text?: string): string {
41+
const displayText = text || url
42+
// OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
43+
return `\x1b]8;;${url}\x07${displayText}\x1b]8;;\x07`
44+
}
45+
3246
export function println(...message: string[]) {
3347
print(...message)
3448
Bun.stderr.write(EOL)

0 commit comments

Comments
 (0)