feat: "Use Claude Code Token" button — imports credentials.json as OAuth fallback
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m5s

Reads %USERPROFILE%\.claude\.credentials.json (or ~/.claude/.credentials.json),
extracts the access and refresh tokens, stores them encrypted in Wraith's vault.
Works when Wraith's own OAuth exchange fails. If Claude Code is authenticated
on the same machine, Wraith piggybacks on the existing token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 12:48:30 -04:00
parent 9e1b8d7b61
commit e916d5942b
3 changed files with 84 additions and 1 deletions

View File

@ -27,7 +27,14 @@
class="w-full px-3 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="handleLogin"
>
Connect to Claude
Connect to Claude (OAuth)
</button>
<button
class="w-full mt-2 px-3 py-2 text-sm font-medium text-[#58a6ff] border border-[#30363d] hover:bg-[#1c2128] rounded transition-colors cursor-pointer"
@click="handleImportClaudeCode"
>
Use Claude Code Token
</button>
<div class="mt-3">
@ -97,6 +104,7 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { Call } from "@wailsio/runtime";
import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot";
@ -108,6 +116,8 @@ const emit = defineEmits<{
(e: "close"): void;
}>();
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
async function handleLogin(): Promise<void> {
await startLogin();
// Auth state will be updated when the OAuth callback completes
@ -116,6 +126,17 @@ async function handleLogin(): Promise<void> {
emit("close");
}
async function handleImportClaudeCode(): Promise<void> {
try {
await Call.ByName(`${APP}.ImportClaudeCodeToken`);
store.isAuthenticated = true;
alert("Claude Code token imported successfully.");
emit("close");
} catch (err: any) {
alert(`Failed to import token: ${err?.message ?? err}`);
}
}
function handleApiKey(): void {
if (!apiKey.value.trim()) return;
// TODO: Wails AIService.SetApiKey(apiKey)

View File

@ -13,6 +13,8 @@ import (
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync"
"time"
@ -401,6 +403,57 @@ func generateState() (string, error) {
return base64.RawURLEncoding.EncodeToString(b), nil
}
// ImportClaudeCodeToken reads the Claude Code credentials.json file and imports
// the OAuth tokens. This is the fallback when Wraith's own OAuth flow fails.
// Path: %USERPROFILE%\.claude\.credentials.json (Windows) or ~/.claude/.credentials.json (macOS/Linux)
func (o *OAuthManager) ImportClaudeCodeToken() error {
home := os.Getenv("USERPROFILE")
if home == "" {
var err error
home, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("find home directory: %w", err)
}
}
credPath := filepath.Join(home, ".claude", ".credentials.json")
data, err := os.ReadFile(credPath)
if err != nil {
return fmt.Errorf("read credentials.json at %s: %w", credPath, err)
}
var creds struct {
ClaudeAiOAuth struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
} `json:"claudeAiOauth"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return fmt.Errorf("parse credentials.json: %w", err)
}
if creds.ClaudeAiOAuth.AccessToken == "" {
return fmt.Errorf("no access token found in credentials.json")
}
// Convert millisecond timestamp to seconds-based ExpiresIn
expiresIn := int((creds.ClaudeAiOAuth.ExpiresAt / 1000) - time.Now().Unix())
if expiresIn < 0 {
expiresIn = 0
}
tokens := tokenResponse{
AccessToken: creds.ClaudeAiOAuth.AccessToken,
RefreshToken: creds.ClaudeAiOAuth.RefreshToken,
ExpiresIn: expiresIn,
}
slog.Info("imported Claude Code token", "expiresInSeconds", expiresIn)
return o.storeTokens(tokens)
}
// SetVault updates the vault reference (called after vault unlock).
func (o *OAuthManager) SetVault(v *vault.VaultService) {
o.mu.Lock()

View File

@ -516,6 +516,15 @@ func (a *WraithApp) DeleteCredential(id int64) error {
return a.Credentials.DeleteCredential(id)
}
// ImportClaudeCodeToken reads the local Claude Code credentials.json and imports
// the OAuth token into Wraith's vault. Fallback for when Wraith's own OAuth fails.
func (a *WraithApp) ImportClaudeCodeToken() error {
if a.oauthMgr == nil {
return fmt.Errorf("OAuth manager not initialized")
}
return a.oauthMgr.ImportClaudeCodeToken()
}
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {