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:
parent
9e1b8d7b61
commit
c139273568
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user