summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/credentials
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src/credentials
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz
dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/core/src/credentials')
-rw-r--r--packages/core/src/credentials/api-keys.ts19
-rw-r--r--packages/core/src/credentials/claude.ts95
-rw-r--r--packages/core/src/credentials/index.ts26
-rw-r--r--packages/core/src/credentials/opencode.ts9
-rw-r--r--packages/core/src/credentials/store.ts26
5 files changed, 111 insertions, 64 deletions
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
index 5f92ffa..af5aa0e 100644
--- a/packages/core/src/credentials/api-keys.ts
+++ b/packages/core/src/credentials/api-keys.ts
@@ -33,9 +33,9 @@ export function setApiKey(keyId: string, provider: string, apiKey: string): void
*/
export function getApiKey(keyId: string): string | null {
const db = getDatabase();
- const row = db.query(
- "SELECT api_key FROM api_keys WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as { api_key: string } | null;
+ const row = db
+ .query("SELECT api_key FROM api_keys WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { api_key: string } | null;
return row?.api_key ?? null;
}
@@ -57,11 +57,16 @@ export function deleteApiKey(keyId: string): void {
/**
* List all stored API keys with metadata (key value excluded for security).
*/
-export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> {
+export function listApiKeys(): Array<{
+ keyId: string;
+ provider: string;
+ importedAt: number;
+ updatedAt: number;
+}> {
const db = getDatabase();
- const rows = db.query(
- "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id",
- ).all() as Array<Record<string, unknown>>;
+ const rows = db
+ .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id")
+ .all() as Array<Record<string, unknown>>;
return rows.map((row) => ({
keyId: row.key_id as string,
provider: row.provider as string,
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 1b9d148..6018207 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -1,9 +1,16 @@
-import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs";
-import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js";
-import { getDatabase } from "../db/index.js";
-import { dirname, join, basename } from "node:path";
-import { homedir } from "node:os";
import { createHash } from "node:crypto";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { getDatabase } from "../db/index.js";
+import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
export interface ClaudeCredentials {
accessToken: string;
@@ -41,7 +48,10 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
const creds = data as Record<string, unknown>;
- if ((creds as Record<string, unknown>).mcpOAuth && !(creds as Record<string, unknown>).accessToken) {
+ if (
+ (creds as Record<string, unknown>).mcpOAuth &&
+ !(creds as Record<string, unknown>).accessToken
+ ) {
return null;
}
@@ -57,7 +67,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
accessToken: creds.accessToken as string,
refreshToken: creds.refreshToken as string,
expiresAt: creds.expiresAt as number,
- subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ subscriptionType:
+ typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
};
}
@@ -122,7 +133,7 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials
return null;
}
- const data = await response.json() as Record<string, unknown>;
+ const data = (await response.json()) as Record<string, unknown>;
if (!data.access_token || typeof data.access_token !== "string") {
return null;
}
@@ -131,7 +142,8 @@ async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials
accessToken: data.access_token as string,
refreshToken: (data.refresh_token as string) ?? refreshToken,
expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000,
- subscriptionType: typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
+ subscriptionType:
+ typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
};
} catch {
return null;
@@ -220,7 +232,11 @@ export function discoverClaudeAccounts(): ClaudeAccount[] {
export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null {
const cached = accountCacheMap.get(account.id);
const now = Date.now();
- if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) {
+ if (
+ cached &&
+ now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
+ cached.creds.expiresAt > now + 60_000
+ ) {
return cached.creds;
}
@@ -257,10 +273,16 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent
return null;
}
-export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Promise<ClaudeCredentials | null> {
+export async function refreshAccountCredentialsAsync(
+ account: ClaudeAccount,
+): Promise<ClaudeCredentials | null> {
const cached = accountCacheMap.get(account.id);
const now = Date.now();
- if (cached && now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS && cached.creds.expiresAt > now + 60_000) {
+ if (
+ cached &&
+ now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
+ cached.creds.expiresAt > now + 60_000
+ ) {
return cached.creds;
}
@@ -294,7 +316,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
account.credentials = refreshed;
// Update DB if this is a DB-backed account, otherwise write to file
if (account.source.startsWith("db:")) {
- updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
+ updateStoredTokens(
+ account.id,
+ refreshed.accessToken,
+ refreshed.refreshToken,
+ refreshed.expiresAt,
+ );
} else {
writeCredentialsFile(account.source, refreshed);
}
@@ -323,14 +350,14 @@ function computeCch(messageText: string): string {
}
function computeVersionSuffix(messageText: string, version: string): string {
- const sampled = [4, 7, 20]
- .map((i) => (i < messageText.length ? messageText[i] : "0"))
- .join("");
+ const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join("");
const input = `${BILLING_SALT}${sampled}${version}`;
return createHash("sha256").update(input).digest("hex").slice(0, 3);
}
-export function buildBillingHeaderValue(messages: Array<{ role: string; content: string }>): string {
+export function buildBillingHeaderValue(
+ messages: Array<{ role: string; content: string }>,
+): string {
const text = extractFirstUserMessageText(messages);
const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION;
const suffix = computeVersionSuffix(text, version);
@@ -404,11 +431,16 @@ export async function fetchAnthropicModels(accessToken: string): Promise<string[
return [];
}
- const data = (await response.json()) as { data?: Array<{ id: string }>; models?: Array<{ id: string }> };
+ const data = (await response.json()) as {
+ data?: Array<{ id: string }>;
+ models?: Array<{ id: string }>;
+ };
const entries = data.data ?? data.models ?? [];
return entries.map((m) => m.id).filter(Boolean);
} catch (err) {
- console.warn(`dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`,
+ );
return [];
}
}
@@ -434,7 +466,9 @@ export interface ClaudeProfile {
* Validate that Claude credentials are usable by hitting the OAuth profile endpoint.
* Returns the profile info if valid, or null if the token is dead.
*/
-export async function validateAccountCredentials(account: ClaudeAccount): Promise<ClaudeProfile | null> {
+export async function validateAccountCredentials(
+ account: ClaudeAccount,
+): Promise<ClaudeProfile | null> {
const creds = await refreshAccountCredentialsAsync(account);
if (!creds) return null;
@@ -487,7 +521,8 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
// API returns utilization as 0-100 percentage; normalize to 0-1 fraction
const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined;
const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined;
- const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
+ const resetsAt =
+ typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
if (utilization === undefined && resetsAt === undefined) return undefined;
return { utilization, resetsAt };
};
@@ -503,9 +538,13 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
// Try to extract identity
const accountId =
- typeof data.account_id === "string" ? data.account_id :
- typeof data.user_id === "string" ? data.user_id :
- typeof data.org_id === "string" ? data.org_id : undefined;
+ typeof data.account_id === "string"
+ ? data.account_id
+ : typeof data.user_id === "string"
+ ? data.user_id
+ : typeof data.org_id === "string"
+ ? data.org_id
+ : undefined;
if (accountId) report.accountId = accountId;
const email = typeof data.email === "string" ? data.email : undefined;
@@ -520,9 +559,9 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
function getCachedUsage(keyId: string): ClaudeUsageReport | null {
try {
const db = getDatabase();
- const row = db.query(
- "SELECT report_json FROM usage_cache WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as { report_json: string } | null;
+ const row = db
+ .query("SELECT report_json FROM usage_cache WHERE key_id = $keyId")
+ .get({ $keyId: keyId }) as { report_json: string } | null;
if (!row) return null;
return JSON.parse(row.report_json) as ClaudeUsageReport;
} catch {
@@ -559,4 +598,4 @@ export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsa
return report;
}
return getCachedUsage(account.id);
-} \ No newline at end of file
+}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 8cbedd9..e7f8a12 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -1,4 +1,12 @@
export {
+ deleteApiKey,
+ getApiKey,
+ listApiKeys,
+ resolveApiKey,
+ type StoredApiKey,
+ setApiKey,
+} from "./api-keys.js";
+export {
ANTHROPIC_MODELS_FALLBACK,
buildBillingHeaderValue,
type ClaudeAccount,
@@ -7,11 +15,11 @@ export {
type ClaudeUsageBucket,
type ClaudeUsageReport,
discoverClaudeAccounts,
- getClaudeAccountsFromDB,
fetchAnthropicModels,
getAccountUsage,
getAnthropicBetas,
getAnthropicHeaders,
+ getClaudeAccountsFromDB,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
SYSTEM_IDENTITY,
@@ -27,18 +35,10 @@ export {
type OpencodeUsageReport,
} from "./opencode.js";
export {
- type StoredCredential,
- importCredentialsFromFile,
- getStoredCredentials,
- updateStoredTokens,
deleteStoredCredentials,
+ getStoredCredentials,
+ importCredentialsFromFile,
listStoredCredentials,
+ type StoredCredential,
+ updateStoredTokens,
} from "./store.js";
-export {
- type StoredApiKey,
- setApiKey,
- getApiKey,
- resolveApiKey,
- deleteApiKey,
- listApiKeys,
-} from "./api-keys.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
index b8a9d4d..d4d4851 100644
--- a/packages/core/src/credentials/opencode.ts
+++ b/packages/core/src/credentials/opencode.ts
@@ -70,9 +70,7 @@ function parseOcBucket(
return { utilization, resetsAt };
}
-export async function fetchOpencodeUsage(
- keyId: string,
-): Promise<OpencodeUsageReport | null> {
+export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> {
const cookie = resolveApiKey("opencode-cookie");
const wsId = getWorkspaceId(keyId);
@@ -96,10 +94,7 @@ export async function fetchOpencodeUsage(
const html = await response.text();
// Auth redirect check
- if (
- html.includes("/auth/authorize") ||
- html.includes('window.location="/auth/authorize"')
- ) {
+ if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) {
return null;
}
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
index 6c814f9..662b322 100644
--- a/packages/core/src/credentials/store.ts
+++ b/packages/core/src/credentials/store.ts
@@ -1,6 +1,6 @@
+import { existsSync, readFileSync } from "node:fs";
import { getDatabase } from "../db/index.js";
import type { ClaudeCredentials } from "./claude.js";
-import { existsSync, readFileSync } from "node:fs";
export interface StoredCredential {
keyId: string;
@@ -41,7 +41,8 @@ function parseCredentialsFile(raw: string): ClaudeCredentials | null {
accessToken: creds.accessToken as string,
refreshToken: creds.refreshToken as string,
expiresAt: creds.expiresAt as number,
- subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ subscriptionType:
+ typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
};
}
@@ -62,7 +63,10 @@ export function importCredentialsFromFile(
try {
raw = readFileSync(filePath, "utf-8").trim();
} catch (e) {
- return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` };
+ return {
+ success: false,
+ error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`,
+ };
}
if (!raw) {
@@ -106,9 +110,11 @@ export function importCredentialsFromFile(
*/
export function getStoredCredentials(keyId: string): StoredCredential | null {
const db = getDatabase();
- const row = db.query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
- ).get({ $keyId: keyId }) as Record<string, unknown> | null;
+ const row = db
+ .query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
+ )
+ .get({ $keyId: keyId }) as Record<string, unknown> | null;
if (!row) return null;
@@ -159,9 +165,11 @@ export function deleteStoredCredentials(keyId: string): void {
*/
export function listStoredCredentials(): StoredCredential[] {
const db = getDatabase();
- const rows = db.query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
- ).all() as Array<Record<string, unknown>>;
+ const rows = db
+ .query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
+ )
+ .all() as Array<Record<string, unknown>>;
return rows.map((row) => ({
keyId: row.key_id as string,