feat: "Use Claude Code Token" button — imports credentials.json as OAuth fallback

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 c139273568
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" 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" @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> </button>
<div class="mt-3"> <div class="mt-3">
@ -97,6 +104,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { Call } from "@wailsio/runtime";
import { useCopilotStore } from "@/stores/copilot.store"; import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot"; import { useCopilot } from "@/composables/useCopilot";
@ -108,6 +116,8 @@ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
}>(); }>();
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
async function handleLogin(): Promise<void> { async function handleLogin(): Promise<void> {
await startLogin(); await startLogin();
// Auth state will be updated when the OAuth callback completes // Auth state will be updated when the OAuth callback completes
@ -116,6 +126,17 @@ async function handleLogin(): Promise<void> {
emit("close"); 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 { function handleApiKey(): void {
if (!apiKey.value.trim()) return; if (!apiKey.value.trim()) return;
// TODO: Wails AIService.SetApiKey(apiKey) // TODO: Wails AIService.SetApiKey(apiKey)

View File

@ -13,6 +13,8 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv" "strconv"
"sync" "sync"
"time" "time"
@ -401,6 +403,57 @@ func generateState() (string, error) {
return base64.RawURLEncoding.EncodeToString(b), nil 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). // SetVault updates the vault reference (called after vault unlock).
func (o *OAuthManager) SetVault(v *vault.VaultService) { func (o *OAuthManager) SetVault(v *vault.VaultService) {
o.mu.Lock() o.mu.Lock()

View File

@ -516,6 +516,15 @@ func (a *WraithApp) DeleteCredential(id int64) error {
return a.Credentials.DeleteCredential(id) 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 // ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database. // (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) { func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {