From ec65fe7bb19d2d6414c5897b980a51060f11c038 Mon Sep 17 00:00:00 2001 From: Kadxy <135022988+Kadxy@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:15:40 +0800 Subject: [PATCH 1/3] refactor: unify config to ~/.codeblog/config.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth/config was split across three files causing stale credentials after re-login (MCP tools kept using old API key from a different file than what OAuth login updated): - ~/.codeblog/config.json (MCP server: apiKey, activeAgent, userId) - ~/.config/codeblog/config.json (CLI: providers, model, flags) - ~/.local/share/codeblog/auth.json (Auth: type, value, username) Now everything lives in ~/.codeblog/config.json with nested schema: { serverUrl, dailyReportHour, auth: { apiKey, ... }, cli: { model, providers, ... } } Key changes: - Global.Path.config → ~/.codeblog/ (was ~/.config/codeblog/) - Auth module → thin proxy over Config (no more auth.json) - Config module → deep merge with undefined-deletes-field semantics - All field names → camelCase (was snake_case) - Remove broken McpBridge.callTool("codeblog_setup", {api_key}) calls - Logout now disconnects MCP + clears tool cache - Tests and docs updated to match new schema Requires codeblog-mcp to be updated to read nested config format before this can be released. See MCP_MIGRATION_PLAN.md. Refs #28, #48 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 32 +-- docs/architecture.md | 6 +- install.ps1 | 10 +- install.sh | 14 +- .../codeblog/src/ai/__tests__/compat.test.ts | 2 +- .../ai/__tests__/provider-registry.test.ts | 80 ++++--- .../src/ai/__tests__/provider.test.ts | 10 +- packages/codeblog/src/ai/codeblog-provider.ts | 2 +- packages/codeblog/src/ai/configure.ts | 38 +-- packages/codeblog/src/ai/models.ts | 8 +- packages/codeblog/src/ai/provider-registry.ts | 8 +- packages/codeblog/src/ai/provider.ts | 24 +- packages/codeblog/src/ai/types.ts | 6 +- packages/codeblog/src/auth/index.ts | 25 +- packages/codeblog/src/auth/oauth.ts | 7 - packages/codeblog/src/cli/cmd/config.ts | 24 +- packages/codeblog/src/cli/cmd/login.ts | 7 - packages/codeblog/src/cli/cmd/logout.ts | 6 +- packages/codeblog/src/cli/cmd/setup.ts | 54 ++--- .../src/config/__tests__/config.test.ts | 222 ++++++++++++++++++ packages/codeblog/src/config/index.ts | 120 +++++----- packages/codeblog/src/global/index.ts | 2 +- packages/codeblog/src/tui/app.tsx | 4 +- packages/codeblog/src/tui/commands.ts | 6 +- packages/codeblog/src/tui/routes/home.tsx | 50 ++-- packages/codeblog/src/tui/routes/model.tsx | 4 +- 26 files changed, 491 insertions(+), 280 deletions(-) create mode 100644 packages/codeblog/src/config/__tests__/config.test.ts diff --git a/README.md b/README.md index 366adcb..cbaeaa0 100644 --- a/README.md +++ b/README.md @@ -191,16 +191,21 @@ Any provider from [models.dev](https://models.dev) can also be used via the Open ### Config file -Config is stored at `~/.config/codeblog/config.json`: +Config is stored at `~/.codeblog/config.json`: ```json { - "api_url": "https://codeblog.ai", - "model": "openai-compatible/claude-sonnet-4-5-20250929", - "providers": { - "openai-compatible": { - "api_key": "sk-...", - "base_url": "https://my-proxy.com" + "serverUrl": "https://codeblog.ai", + "auth": { + "apiKey": "cbk_..." + }, + "cli": { + "model": "openai-compatible/claude-sonnet-4-5-20250929", + "providers": { + "openai-compatible": { + "apiKey": "sk-...", + "baseUrl": "https://my-proxy.com" + } } } } @@ -313,14 +318,13 @@ Sessions are scanned via the [MCP server](https://github.com/TIANQIAN1238/codebl ## File Locations -Config and data follow the [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/latest/) spec: +Config and data follow Claude Code conventions — config in `~/.codeblog/`, data in XDG: ``` -~/.config/codeblog/ -├── config.json # Server URL, AI providers, model +~/.codeblog/ +├── config.json # Auth, server URL, AI providers, model ├── theme.json # TUI color theme ~/.local/share/codeblog/ -├── auth.json # OAuth token ├── codeblog.db # SQLite (chat history, etc.) ├── bin/ # CLI binary (curl install) └── log/ @@ -382,7 +386,7 @@ codeblog-app/ │ │ │ ├── mcp/ # MCP Bridge (spawns codeblog-mcp) │ │ │ ├── auth/ # OAuth login + token management │ │ │ ├── storage/ # Local SQLite (chat history) -│ │ │ └── config/ # User config (~/.config/codeblog/) +│ │ │ └── config/ # User config (~/.codeblog/) │ │ └── bin/codeblog # npm bin entry │ └── util/ # @codeblog-ai/util — shared utilities ├── scripts/ # build, clean, release @@ -405,7 +409,7 @@ If installed via curl: ```bash rm -f ~/.local/bin/codeblog -rm -rf ~/.config/codeblog ~/.local/share/codeblog ~/.cache/codeblog +rm -rf ~/.codeblog ~/.local/share/codeblog ~/.cache/codeblog # Remove the "# codeblog" PATH entry from your ~/.zshrc / ~/.bashrc ``` @@ -413,7 +417,7 @@ If installed via npm / bun: ```bash npm uninstall -g codeblog-app # or: bun remove -g codeblog-app -rm -rf ~/.config/codeblog ~/.local/share/codeblog ~/.cache/codeblog +rm -rf ~/.codeblog ~/.local/share/codeblog ~/.cache/codeblog ``` --- diff --git a/docs/architecture.md b/docs/architecture.md index d247b0e..0b04f4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ codeblog-app/ │ │ │ ┌─────────┐ ┌──────────┐ ┌───────────┐ │ │ │ Auth │──▶│ Config │──▶│ Flag │ │ -│ │ (OAuth) │ │ (~/.cb/) │ │ (env var) │ │ +│ │ (OAuth) │ │(~/.codeblog)│ │ (env var) │ │ │ └─────────┘ └──────────┘ └───────────┘ │ └──────────────────────────────────────────────────────────────────┘ ``` @@ -113,7 +113,7 @@ The API client maps 1:1 to CodeBlog's REST API v1: ## Storage -Local SQLite database at `~/.codeblog/data/codeblog.db`: +Local SQLite database at `~/.local/share/codeblog/codeblog.db`: - `published_sessions` — dedup: tracks which sessions have been posted - `cached_posts` — offline cache of fetched posts @@ -136,6 +136,6 @@ Browser completes OAuth flow │ ▼ CLI receives callback - ├── Store API key in ~/.codeblog/auth.json + ├── Store API key in ~/.codeblog/config.json (auth.apiKey) └── Stop local server ``` diff --git a/install.ps1 b/install.ps1 index aec5844..afc177a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -212,15 +212,9 @@ function Write-OutroUpdate { # ── Setup check ────────────────────────────────────────────────────────────── function Test-NeedsSetup { - $appData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $env:USERPROFILE "AppData\Local" } - $roamingData = if ($env:APPDATA) { $env:APPDATA } else { Join-Path $env:USERPROFILE "AppData\Roaming" } - $authFile = Join-Path $appData "codeblog\auth.json" - $configFile = Join-Path $roamingData "codeblog\config.json" + $configFile = Join-Path $env:USERPROFILE ".codeblog\config.json" - if (-not (Test-Path $authFile) -or -not (Select-String -Path $authFile -Pattern '"value"' -Quiet -ErrorAction SilentlyContinue)) { - return $true - } - if (-not (Test-Path $configFile) -or -not (Select-String -Path $configFile -Pattern '"api_key"' -Quiet -ErrorAction SilentlyContinue)) { + if (-not (Test-Path $configFile) -or -not (Select-String -Path $configFile -Pattern '"apiKey"' -Quiet -ErrorAction SilentlyContinue)) { return $true } return $false diff --git a/install.sh b/install.sh index 3e19286..997521e 100755 --- a/install.sh +++ b/install.sh @@ -199,20 +199,10 @@ install_binary() { NEEDS_SETUP=0 check_needs_setup() { - # XDG paths matching the app's Global.Path (src/global/index.ts) - local xdg_data="${XDG_DATA_HOME:-$HOME/.local/share}" - local xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}" - local auth_file="$xdg_data/codeblog/auth.json" - local config_file="$xdg_config/codeblog/config.json" + local config_file="$HOME/.codeblog/config.json" # Not logged in? - if [ ! -f "$auth_file" ] || ! grep -q '"value"' "$auth_file" 2>/dev/null; then - NEEDS_SETUP=1 - return - fi - - # No AI provider configured? - if [ ! -f "$config_file" ] || ! grep -q '"api_key"' "$config_file" 2>/dev/null; then + if [ ! -f "$config_file" ] || ! grep -q '"apiKey"' "$config_file" 2>/dev/null; then NEEDS_SETUP=1 return fi diff --git a/packages/codeblog/src/ai/__tests__/compat.test.ts b/packages/codeblog/src/ai/__tests__/compat.test.ts index 00fac7e..ceb336b 100644 --- a/packages/codeblog/src/ai/__tests__/compat.test.ts +++ b/packages/codeblog/src/ai/__tests__/compat.test.ts @@ -16,7 +16,7 @@ describe("AI Compat", () => { const compat = resolveCompat({ providerID: "openai-compatible", modelID: "claude-sonnet-4-20250514", - providerConfig: { api_key: "x", api: "anthropic", compat_profile: "anthropic" }, + providerConfig: { apiKey: "x", apiType: "anthropic", compatProfile: "anthropic" }, }) expect(compat.api).toBe("anthropic") expect(compat.compatProfile).toBe("anthropic") diff --git a/packages/codeblog/src/ai/__tests__/provider-registry.test.ts b/packages/codeblog/src/ai/__tests__/provider-registry.test.ts index 7c00c9d..407da7c 100644 --- a/packages/codeblog/src/ai/__tests__/provider-registry.test.ts +++ b/packages/codeblog/src/ai/__tests__/provider-registry.test.ts @@ -11,25 +11,29 @@ describe("provider-registry", () => { test("routes explicit provider/model first", async () => { const route = await routeModel("openai/gpt-4o", { - api_url: "https://codeblog.ai", - providers: { - openai: { api_key: "sk-openai" }, + serverUrl: "https://codeblog.ai", + cli: { + providers: { + openai: { apiKey: "sk-openai" }, + }, }, }) expect(route.providerID).toBe("openai") expect(route.modelID).toBe("gpt-4o") }) - test("routes by default_provider for unknown model", async () => { + test("routes by defaultProvider for unknown model", async () => { const route = await routeModel("deepseek-chat", { - api_url: "https://codeblog.ai", - default_provider: "openai-compatible", - providers: { - "openai-compatible": { - api_key: "sk-compat", - base_url: "https://api.deepseek.com", - api: "openai-compatible", - compat_profile: "openai-compatible", + serverUrl: "https://codeblog.ai", + cli: { + defaultProvider: "openai-compatible", + providers: { + "openai-compatible": { + apiKey: "sk-compat", + baseUrl: "https://api.deepseek.com", + apiType: "openai-compatible", + compatProfile: "openai-compatible", + }, }, }, }) @@ -39,20 +43,24 @@ describe("provider-registry", () => { test("unknown model throws deterministic actionable error", async () => { await expect(routeModel("unknown-model-x", { - api_url: "https://codeblog.ai", - providers: { - openai: { api_key: "sk-openai" }, + serverUrl: "https://codeblog.ai", + cli: { + providers: { + openai: { apiKey: "sk-openai" }, + }, }, })).rejects.toThrow('Unknown model "unknown-model-x"') }) test("multi-provider routing is deterministic by prefix", async () => { const route = await routeModel("gpt-4o-mini", { - api_url: "https://codeblog.ai", - default_provider: "openai-compatible", - providers: { - openai: { api_key: "sk-openai" }, - "openai-compatible": { api_key: "sk-compat", base_url: "https://api.deepseek.com" }, + serverUrl: "https://codeblog.ai", + cli: { + defaultProvider: "openai-compatible", + providers: { + openai: { apiKey: "sk-openai" }, + "openai-compatible": { apiKey: "sk-compat", baseUrl: "https://api.deepseek.com" }, + }, }, }) expect(route.providerID).toBe("openai") @@ -61,11 +69,13 @@ describe("provider-registry", () => { test("legacy model id is normalized to stable default", async () => { const route = await routeModel(undefined, { - api_url: "https://codeblog.ai", - default_provider: "openai", - model: "4.0Ultra", - providers: { - openai: { api_key: "sk-openai" }, + serverUrl: "https://codeblog.ai", + cli: { + defaultProvider: "openai", + model: "4.0Ultra", + providers: { + openai: { apiKey: "sk-openai" }, + }, }, }) expect(route.providerID).toBe("openai") @@ -74,10 +84,12 @@ describe("provider-registry", () => { test("missing model uses provider-specific default", async () => { const route = await routeModel(undefined, { - api_url: "https://codeblog.ai", - default_provider: "openai", - providers: { - openai: { api_key: "sk-openai" }, + serverUrl: "https://codeblog.ai", + cli: { + defaultProvider: "openai", + providers: { + openai: { apiKey: "sk-openai" }, + }, }, }) expect(route.providerID).toBe("openai") @@ -86,10 +98,12 @@ describe("provider-registry", () => { test("missing model on openai-compatible keeps provider prefix", async () => { const route = await routeModel(undefined, { - api_url: "https://codeblog.ai", - default_provider: "openai-compatible", - providers: { - "openai-compatible": { api_key: "sk-compat", base_url: "https://example.com/v1", api: "openai-compatible" }, + serverUrl: "https://codeblog.ai", + cli: { + defaultProvider: "openai-compatible", + providers: { + "openai-compatible": { apiKey: "sk-compat", baseUrl: "https://example.com/v1", apiType: "openai-compatible" }, + }, }, }) expect(route.providerID).toBe("openai-compatible") diff --git a/packages/codeblog/src/ai/__tests__/provider.test.ts b/packages/codeblog/src/ai/__tests__/provider.test.ts index b9316cc..245c1f4 100644 --- a/packages/codeblog/src/ai/__tests__/provider.test.ts +++ b/packages/codeblog/src/ai/__tests__/provider.test.ts @@ -7,7 +7,7 @@ import { Config } from "../../config" describe("AIProvider", () => { const originalEnv = { ...process.env } const testHome = path.join(os.tmpdir(), `codeblog-provider-test-${process.pid}-${Date.now()}`) - const configFile = path.join(testHome, ".config", "codeblog", "config.json") + const configFile = path.join(testHome, ".codeblog", "config.json") const xdgData = path.join(testHome, ".local", "share") const xdgCache = path.join(testHome, ".cache") const xdgConfig = path.join(testHome, ".config") @@ -207,7 +207,7 @@ describe("AIProvider", () => { test("getModel throws when no API key for builtin model", async () => { const load = Config.load - Config.load = async () => ({ api_url: "https://codeblog.ai" }) + Config.load = async () => ({ serverUrl: "https://codeblog.ai" }) try { await expect(AIProvider.getModel("gpt-4o")).rejects.toThrow("No API key for openai") } finally { @@ -215,10 +215,10 @@ describe("AIProvider", () => { } }) - test("getModel falls back to provider with base_url for unknown model", async () => { - // When a provider with base_url is configured, unknown models get sent there + test("getModel falls back to provider with baseUrl for unknown model", async () => { + // When a provider with baseUrl is configured, unknown models get sent there // instead of throwing. This test verifies the fallback behavior. - // If no provider has a base_url, it would throw. + // If no provider has a baseUrl, it would throw. const result = AIProvider.getModel("nonexistent-model-xyz") // Either resolves (provider with base_url available) or rejects const settled = await Promise.allSettled([result]) diff --git a/packages/codeblog/src/ai/codeblog-provider.ts b/packages/codeblog/src/ai/codeblog-provider.ts index 66e022b..e1b4ec0 100644 --- a/packages/codeblog/src/ai/codeblog-provider.ts +++ b/packages/codeblog/src/ai/codeblog-provider.ts @@ -45,7 +45,7 @@ type FetchFn = ( export async function getCodeblogFetch(): Promise { const cfg = await Config.load() - const proxyURL = (cfg.providers?.codeblog?.base_url || `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat`).replace(/\/+$/, "") + const proxyURL = (cfg.cli?.providers?.codeblog?.baseUrl || `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat`).replace(/\/+$/, "") return async (input, init) => { const headers = new Headers(init?.headers) diff --git a/packages/codeblog/src/ai/configure.ts b/packages/codeblog/src/ai/configure.ts index c8a77d4..4307dff 100644 --- a/packages/codeblog/src/ai/configure.ts +++ b/packages/codeblog/src/ai/configure.ts @@ -90,29 +90,29 @@ export async function saveProvider(url: string, key: string): Promise<{ provider process.env[envBase] = url const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} providers[provider] = { - api_key: key, - base_url: url, - api: detected === "anthropic" ? "anthropic" : "openai-compatible", - compat_profile: detected === "anthropic" ? "anthropic" : "openai-compatible", + apiKey: key, + baseUrl: url, + apiType: detected === "anthropic" ? "anthropic" : "openai-compatible", + compatProfile: detected === "anthropic" ? "anthropic" : "openai-compatible", } // Auto-set model if not already configured - const update: Record = { providers, default_provider: provider } - if (!cfg.model) { + const cliUpdate: Record = { providers, defaultProvider: provider } + if (!cfg.cli?.model) { const { defaultModelForProvider } = await import("./models") if (detected === "anthropic") { - update.model = defaultModelForProvider("anthropic") + cliUpdate.model = defaultModelForProvider("anthropic") } else { // For openai-compatible with custom URL, try to fetch available models const model = await fetchFirstModel(url, key) - if (model) update.model = `openai-compatible/${model}` - else update.model = `openai-compatible/${defaultModelForProvider("openai-compatible")}` + if (model) cliUpdate.model = `openai-compatible/${model}` + else cliUpdate.model = `openai-compatible/${defaultModelForProvider("openai-compatible")}` } } - await Config.save(update) + await Config.save({ cli: cliUpdate as Config.CliConfig }) return { provider: `${detected} format` } } @@ -120,21 +120,21 @@ export async function saveProvider(url: string, key: string): Promise<{ provider if (ENV_MAP[provider]) process.env[ENV_MAP[provider]] = key const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} providers[provider] = { - api_key: key, - api: provider === "anthropic" ? "anthropic" : provider === "google" ? "google" : provider === "openai" ? "openai" : "openai-compatible", - compat_profile: provider === "anthropic" ? "anthropic" : provider === "google" ? "google" : provider === "openai" ? "openai" : "openai-compatible", + apiKey: key, + apiType: provider === "anthropic" ? "anthropic" : provider === "google" ? "google" : provider === "openai" ? "openai" : "openai-compatible", + compatProfile: provider === "anthropic" ? "anthropic" : provider === "google" ? "google" : provider === "openai" ? "openai" : "openai-compatible", } // Auto-set model for known providers - const update: Record = { providers, default_provider: provider } - if (!cfg.model) { + const cliUpdate2: Record = { providers, defaultProvider: provider } + if (!cfg.cli?.model) { const { defaultModelForProvider } = await import("./models") - update.model = defaultModelForProvider(provider) + cliUpdate2.model = defaultModelForProvider(provider) } - await Config.save(update) + await Config.save({ cli: cliUpdate2 as Config.CliConfig }) return { provider } } diff --git a/packages/codeblog/src/ai/models.ts b/packages/codeblog/src/ai/models.ts index 04e0062..1a31876 100644 --- a/packages/codeblog/src/ai/models.ts +++ b/packages/codeblog/src/ai/models.ts @@ -49,11 +49,11 @@ export function defaultModelForProvider(providerID?: string): string { return PROVIDER_DEFAULT_MODEL[providerID] || DEFAULT_MODEL } -export function resolveModelFromConfig(cfg: { model?: string; default_provider?: string }): string { - const model = normalizeModelID(cfg.model) +export function resolveModelFromConfig(cfg: { cli?: { model?: string; defaultProvider?: string } }): string { + const model = normalizeModelID(cfg.cli?.model) if (model) return model - const fallback = defaultModelForProvider(cfg.default_provider) - if (cfg.default_provider === "openai-compatible" && !fallback.includes("/")) { + const fallback = defaultModelForProvider(cfg.cli?.defaultProvider) + if (cfg.cli?.defaultProvider === "openai-compatible" && !fallback.includes("/")) { return `openai-compatible/${fallback}` } return fallback diff --git a/packages/codeblog/src/ai/provider-registry.ts b/packages/codeblog/src/ai/provider-registry.ts index 49af2a4..3977b1f 100644 --- a/packages/codeblog/src/ai/provider-registry.ts +++ b/packages/codeblog/src/ai/provider-registry.ts @@ -49,7 +49,7 @@ function readFirstEnv(keys: string[]): string | undefined { export async function loadProviders(cfgInput?: Config.CodeblogConfig): Promise { const cfg = cfgInput || await Config.load() - const user = cfg.providers || {} + const user = cfg.cli?.providers || {} const ids = new Set([ ...Object.keys(PROVIDER_ENV), ...Object.keys(user), @@ -62,12 +62,12 @@ export async function loadProviders(cfgInput?: Config.CodeblogConfig): Promise

): string[] { diff --git a/packages/codeblog/src/ai/provider.ts b/packages/codeblog/src/ai/provider.ts index 5f9c1aa..bf52c06 100644 --- a/packages/codeblog/src/ai/provider.ts +++ b/packages/codeblog/src/ai/provider.ts @@ -40,7 +40,7 @@ export namespace AIProvider { if (process.env[key]) return process.env[key] } const cfg = await Config.load() - return cfg.providers?.[providerID]?.api_key + return cfg.cli?.providers?.[providerID]?.apiKey } export async function getBaseUrl(providerID: string): Promise { @@ -49,7 +49,7 @@ export namespace AIProvider { if (process.env[key]) return process.env[key] } const cfg = await Config.load() - return cfg.providers?.[providerID]?.base_url + return cfg.cli?.providers?.[providerID]?.baseUrl } export async function listProviders(): Promise> { @@ -89,7 +89,7 @@ export namespace AIProvider { } export async function getModel(modelID?: string): Promise { - const useRegistry = await Config.featureEnabled("ai_provider_registry_v2") + const useRegistry = await Config.featureEnabled("aiProviderRegistryV2") if (useRegistry) { const route = await routeModel(modelID) const customFetch = route.providerID === "codeblog" ? await loadCodeblogFetch() : undefined @@ -99,7 +99,7 @@ export namespace AIProvider { } export async function resolveModelCompat(modelID?: string): Promise { - const useRegistry = await Config.featureEnabled("ai_provider_registry_v2") + const useRegistry = await Config.featureEnabled("aiProviderRegistryV2") if (useRegistry) return (await routeModel(modelID)).compat return (await resolveLegacyRoute(modelID)).compat } @@ -130,7 +130,7 @@ export namespace AIProvider { modelID: requested, apiKey, baseURL, - compat: resolveCompat({ providerID: builtin.providerID, modelID: requested, providerConfig: cfg.providers?.[builtin.providerID] }), + compat: resolveCompat({ providerID: builtin.providerID, modelID: requested, providerConfig: cfg.cli?.providers?.[builtin.providerID] }), } } @@ -145,20 +145,20 @@ export namespace AIProvider { modelID, apiKey, baseURL, - compat: resolveCompat({ providerID: providerID!, modelID, providerConfig: cfg.providers?.[providerID!] }), + compat: resolveCompat({ providerID: providerID!, modelID, providerConfig: cfg.cli?.providers?.[providerID!] }), } } - if (cfg.providers) { - for (const [providerID, p] of Object.entries(cfg.providers)) { - if (!p.api_key) continue - const baseURL = p.base_url || (await getBaseUrl(providerID)) + if (cfg.cli?.providers) { + for (const [providerID, p] of Object.entries(cfg.cli.providers)) { + if (!p.apiKey) continue + const baseURL = p.baseUrl || (await getBaseUrl(providerID)) if (!baseURL) continue - log.info("legacy fallback: unknown model routed to first provider with base_url", { provider: providerID, model: requested }) + log.info("legacy fallback: unknown model routed to first provider with baseUrl", { provider: providerID, model: requested }) return { providerID, modelID: requested, - apiKey: p.api_key, + apiKey: p.apiKey, baseURL, compat: resolveCompat({ providerID, modelID: requested, providerConfig: p }), } diff --git a/packages/codeblog/src/ai/types.ts b/packages/codeblog/src/ai/types.ts index d638d59..503cb55 100644 --- a/packages/codeblog/src/ai/types.ts +++ b/packages/codeblog/src/ai/types.ts @@ -50,10 +50,10 @@ function isOfficialOpenAIBase(baseURL?: string): boolean { } function resolveApiFromProvider(providerID: string, cfg?: Config.ProviderConfig): ModelApi { - if (providerID === "openai" && cfg?.base_url && !isOfficialOpenAIBase(cfg.base_url)) { + if (providerID === "openai" && cfg?.baseUrl && !isOfficialOpenAIBase(cfg.baseUrl)) { return "openai-compatible" } - if (cfg?.api) return cfg.api + if (cfg?.apiType) return cfg.apiType if (providerID === "anthropic") return "anthropic" if (providerID === "openai") return "openai" if (providerID === "google") return "google" @@ -74,7 +74,7 @@ export function resolveCompat(args: { providerConfig?: Config.ProviderConfig }): ModelCompatConfig { const api = resolveApiFromProvider(args.providerID, args.providerConfig) - const configured = args.providerConfig?.compat_profile + const configured = args.providerConfig?.compatProfile const compatProfile = api === "openai-compatible" && configured === "openai" ? "openai-compatible" : configured || defaultCompatForApi(api) diff --git a/packages/codeblog/src/auth/index.ts b/packages/codeblog/src/auth/index.ts index ee1e3d8..5be4f5e 100644 --- a/packages/codeblog/src/auth/index.ts +++ b/packages/codeblog/src/auth/index.ts @@ -1,7 +1,5 @@ -import path from "path" -import { chmod, writeFile } from "fs/promises" -import { Global } from "../global" import z from "zod" +import { Config } from "../config" export namespace Auth { export const Token = z @@ -14,36 +12,27 @@ export namespace Auth { .meta({ ref: "AuthToken" }) export type Token = z.infer - const filepath = path.join(Global.Path.data, "auth.json") - export async function get(): Promise { - const file = Bun.file(filepath) - const data = await file.json().catch(() => null) - if (!data) return null - const parsed = Token.safeParse(data) - if (!parsed.success) return null - return parsed.data + const cfg = await Config.load() + if (!cfg.auth?.apiKey) return null + return { type: "apikey", value: cfg.auth.apiKey, username: cfg.auth.username } } export async function set(token: Token) { - await writeFile(filepath, JSON.stringify(token, null, 2)) - await chmod(filepath, 0o600).catch(() => {}) + await Config.save({ auth: { apiKey: token.value, username: token.username } }) } export async function remove() { - const fs = await import("fs/promises") - await fs.unlink(filepath).catch(() => {}) + await Config.save({ auth: { apiKey: undefined, userId: undefined, activeAgent: undefined, username: undefined } }) } export async function header(): Promise> { const token = await get() if (!token) return {} - if (token.type === "apikey") return { Authorization: `Bearer ${token.value}` } return { Authorization: `Bearer ${token.value}` } } export async function authenticated(): Promise { - const token = await get() - return token !== null + return (await get()) !== null } } diff --git a/packages/codeblog/src/auth/oauth.ts b/packages/codeblog/src/auth/oauth.ts index 3268580..2555abd 100644 --- a/packages/codeblog/src/auth/oauth.ts +++ b/packages/codeblog/src/auth/oauth.ts @@ -1,6 +1,5 @@ import { Auth } from "./index" import { Config } from "../config" -import { McpBridge } from "../mcp/client" import { Server } from "../server" import { Log } from "../util/log" @@ -58,12 +57,6 @@ export namespace OAuth { throw new Error(ownerMismatch) } } else { - // Sync API key to MCP config (~/.codeblog/config.json) - try { - await McpBridge.callTool("codeblog_setup", { api_key: key }) - } catch (err) { - log.warn("failed to sync API key to MCP config", { error: String(err) }) - } log.info("authenticated with api key") } } else if (token) { diff --git a/packages/codeblog/src/cli/cmd/config.ts b/packages/codeblog/src/cli/cmd/config.ts index dceb919..89b034e 100644 --- a/packages/codeblog/src/cli/cmd/config.ts +++ b/packages/codeblog/src/cli/cmd/config.ts @@ -86,12 +86,12 @@ export const ConfigCommand: CommandModule = { if (args.provider && (args.apiKey || args.baseUrl)) { const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} const existing = providers[args.provider as string] || {} as Config.ProviderConfig - if (args.apiKey) existing.api_key = args.apiKey as string - if (args.baseUrl) existing.base_url = args.baseUrl as string + if (args.apiKey) existing.apiKey = args.apiKey as string + if (args.baseUrl) existing.baseUrl = args.baseUrl as string providers[args.provider as string] = existing - await Config.save({ providers }) + await Config.save({ cli: { providers } }) const parts: string[] = [] if (args.apiKey) parts.push("API key") if (args.baseUrl) parts.push(`base URL (${args.baseUrl})`) @@ -100,19 +100,19 @@ export const ConfigCommand: CommandModule = { } if (args.model) { - await Config.save({ model: args.model as string }) + await Config.save({ cli: { model: args.model as string } }) UI.success(`Default model set to ${args.model}`) return } if (args.url) { - await Config.save({ api_url: args.url as string }) + await Config.save({ serverUrl: args.url as string }) UI.success(`Server URL set to ${args.url}`) return } if (args.language) { - await Config.save({ default_language: args.language as string }) + await Config.save({ cli: { defaultLanguage: args.language as string } }) UI.success(`Default language set to ${args.language}`) return } @@ -121,22 +121,22 @@ export const ConfigCommand: CommandModule = { const cfg = await Config.load() const { resolveModelFromConfig } = await import("../../ai/models") const model = resolveModelFromConfig(cfg) || AIProvider.DEFAULT_MODEL - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} console.log("") console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Current Config${UI.Style.TEXT_NORMAL}`) console.log(` ${UI.Style.TEXT_DIM}${Config.filepath}${UI.Style.TEXT_NORMAL}`) console.log("") console.log(` Model: ${UI.Style.TEXT_HIGHLIGHT}${model}${UI.Style.TEXT_NORMAL}`) - console.log(` API URL: ${cfg.api_url || "https://codeblog.ai"}`) - console.log(` Language: ${cfg.default_language || `${UI.Style.TEXT_DIM}(server default)${UI.Style.TEXT_NORMAL}`}`) + console.log(` API URL: ${cfg.serverUrl || "https://codeblog.ai"}`) + console.log(` Language: ${cfg.cli?.defaultLanguage || `${UI.Style.TEXT_DIM}(server default)${UI.Style.TEXT_NORMAL}`}`) console.log("") if (Object.keys(providers).length > 0) { console.log(` ${UI.Style.TEXT_NORMAL_BOLD}AI Providers${UI.Style.TEXT_NORMAL}`) for (const [id, p] of Object.entries(providers)) { - const masked = p.api_key ? p.api_key.slice(0, 8) + "..." : "not set" - const url = p.base_url ? ` → ${p.base_url}` : "" + const masked = p.apiKey ? p.apiKey.slice(0, 8) + "..." : "not set" + const url = p.baseUrl ? ` → ${p.baseUrl}` : "" console.log(` ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${id}: ${UI.Style.TEXT_DIM}${masked}${url}${UI.Style.TEXT_NORMAL}`) } } else { diff --git a/packages/codeblog/src/cli/cmd/login.ts b/packages/codeblog/src/cli/cmd/login.ts index d0f61d9..0078e23 100644 --- a/packages/codeblog/src/cli/cmd/login.ts +++ b/packages/codeblog/src/cli/cmd/login.ts @@ -1,7 +1,6 @@ import type { CommandModule } from "yargs" import { OAuth } from "../../auth/oauth" import { Auth } from "../../auth" -import { McpBridge } from "../../mcp/client" import { UI } from "../ui" export const LoginCommand: CommandModule = { @@ -23,12 +22,6 @@ export const LoginCommand: CommandModule = { if (args.key) { const key = args.key as string await Auth.set({ type: "apikey", value: key }) - // Sync API key to MCP config (~/.codeblog/config.json) - try { - await McpBridge.callTool("codeblog_setup", { api_key: key }) - } catch { - // Non-fatal: MCP sync failed but CLI auth is saved - } UI.success("Logged in with API key") return } diff --git a/packages/codeblog/src/cli/cmd/logout.ts b/packages/codeblog/src/cli/cmd/logout.ts index ce1a2f8..4704c2a 100644 --- a/packages/codeblog/src/cli/cmd/logout.ts +++ b/packages/codeblog/src/cli/cmd/logout.ts @@ -1,14 +1,16 @@ import type { CommandModule } from "yargs" import { Auth } from "../../auth" -import { Config } from "../../config" import { UI } from "../ui" export const LogoutCommand: CommandModule = { command: "logout", describe: "Logout from CodeBlog", handler: async () => { + const { McpBridge } = await import("../../mcp/client") + const { clearChatToolsCache } = await import("../../ai/tools") await Auth.remove() - await Config.clearActiveAgent() + await McpBridge.disconnect() + clearChatToolsCache() UI.success("Logged out successfully") }, } diff --git a/packages/codeblog/src/cli/cmd/setup.ts b/packages/codeblog/src/cli/cmd/setup.ts index d8ac755..a9e49d8 100644 --- a/packages/codeblog/src/cli/cmd/setup.ts +++ b/packages/codeblog/src/cli/cmd/setup.ts @@ -356,7 +356,7 @@ function isOfficialOpenAIBase(baseURL: string): boolean { } } -async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: string): Promise<{ ok: boolean; detail: string; detectedApi?: Config.ModelApi }> { +async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: string): Promise<{ ok: boolean; detail: string; detectedApi?: Config.ApiType }> { try { if (choice.api === "anthropic") { const clean = baseURL.replace(/\/+$/, "") @@ -383,7 +383,7 @@ async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: stri const detected = await probe(baseURL, key) if (detected === "anthropic") return { ok: true, detail: "Detected Anthropic API format", detectedApi: "anthropic" } if (detected === "openai") { - const detectedApi: Config.ModelApi = + const detectedApi: Config.ApiType = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" : "openai-compatible" @@ -392,7 +392,7 @@ async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: stri const models = await fetchOpenAIModels(baseURL, key) if (models.length > 0) { - const detectedApi: Config.ModelApi = + const detectedApi: Config.ApiType = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" : "openai-compatible" @@ -501,18 +501,20 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"): const proxyURL = `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat` const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} providers["codeblog"] = { - api_key: "proxy", - base_url: proxyURL, - api: "openai-compatible", - compat_profile: "openai-compatible", + apiKey: "proxy", + baseUrl: proxyURL, + apiType: "openai-compatible", + compatProfile: "openai-compatible", } await Config.save({ - providers, - default_provider: "codeblog", - model: `codeblog/${balance.model}`, + cli: { + providers, + defaultProvider: "codeblog", + model: `codeblog/${balance.model}`, + }, }) UI.success(`AI configured: CodeBlog Credit (${balance.model})`) @@ -567,7 +569,7 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"): } let verified = false - let detectedApi: Config.ModelApi | undefined + let detectedApi: Config.ApiType | undefined while (!verified) { await shimmerLine("Verifying endpoint...", 900) @@ -589,17 +591,17 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"): return } const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} const resolvedApi = detectedApi || provider.api const resolvedCompat = provider.providerID === "openai-compatible" && resolvedApi === "openai" ? "openai-compatible" : resolvedApi const providerConfig: Config.ProviderConfig = { - api_key: key, - api: resolvedApi, - compat_profile: resolvedCompat, + apiKey: key, + apiType: resolvedApi, + compatProfile: resolvedCompat, } - if (baseURL) providerConfig.base_url = baseURL + if (baseURL) providerConfig.baseUrl = baseURL providers[provider.providerID] = providerConfig const model = provider.providerID === "openai-compatible" && !selectedModel.includes("/") @@ -607,9 +609,11 @@ export async function runAISetupWizard(source: "setup" | "command" = "command"): : selectedModel await Config.save({ - providers, - default_provider: provider.providerID, - model, + cli: { + providers, + defaultProvider: provider.providerID, + model, + }, }) UI.success(`AI configured: ${provider.name} (${model})`) @@ -763,11 +767,6 @@ async function agentSelectionPrompt(): Promise { await Auth.set({ type: "apikey", value: switchData.agent.api_key, username: auth.username }) await Config.saveActiveAgent(switchData.agent.name, auth.username) - // Sync to MCP config - try { - await McpBridge.callTool("codeblog_setup", { api_key: switchData.agent.api_key }) - } catch {} - UI.success(`Active agent: ${switchData.agent.name}`) } else { UI.error("Failed to switch agent. You can switch later with: codeblog agent switch") @@ -864,11 +863,6 @@ async function agentCreationWizard(): Promise { await Auth.set({ type: "apikey", value: result.api_key, username: auth?.username }) await Config.saveActiveAgent(result.name, auth?.username) - // Sync to MCP config - try { - await McpBridge.callTool("codeblog_setup", { api_key: result.api_key }) - } catch {} - console.log("") UI.success(`Your agent "${emoji} ${name}" is ready! It'll represent you on CodeBlog.`) } catch (err) { diff --git a/packages/codeblog/src/config/__tests__/config.test.ts b/packages/codeblog/src/config/__tests__/config.test.ts new file mode 100644 index 0000000..c5fe4a9 --- /dev/null +++ b/packages/codeblog/src/config/__tests__/config.test.ts @@ -0,0 +1,222 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test" + +describe("Config unified read/write", () => { + const testHome = path.join(os.tmpdir(), `codeblog-config-test-${process.pid}-${Date.now()}`) + const configDir = path.join(testHome, ".codeblog") + const configFile = path.join(configDir, "config.json") + + let Config: (typeof import("../../config"))["Config"] + let Auth: (typeof import("../../auth"))["Auth"] + + beforeAll(async () => { + process.env.CODEBLOG_TEST_HOME = testHome + await fs.mkdir(configDir, { recursive: true }) + await fs.writeFile(configFile, "{}\n") + ;({ Config } = await import("../../config")) + ;({ Auth } = await import("../../auth")) + }) + + beforeEach(async () => { + await fs.writeFile(configFile, "{}\n") + }) + + afterAll(async () => { + await fs.rm(testHome, { recursive: true, force: true }) + delete process.env.CODEBLOG_TEST_HOME + }) + + // --- Config.save / Config.load --- + + test("save and load serverUrl at top level", async () => { + await Config.save({ serverUrl: "https://test.codeblog.ai" }) + const cfg = await Config.load() + expect(cfg.serverUrl).toBe("https://test.codeblog.ai") + }) + + test("save and load dailyReportHour at top level", async () => { + await Config.save({ dailyReportHour: 18 }) + const cfg = await Config.load() + expect(cfg.dailyReportHour).toBe(18) + }) + + test("save auth nested fields", async () => { + await Config.save({ auth: { apiKey: "cbk_test123", activeAgent: "my-agent", userId: "user123" } }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBe("cbk_test123") + expect(cfg.auth?.activeAgent).toBe("my-agent") + expect(cfg.auth?.userId).toBe("user123") + }) + + test("save cli nested fields", async () => { + await Config.save({ + cli: { + model: "anthropic/claude-sonnet-4", + defaultProvider: "anthropic", + providers: { + anthropic: { apiKey: "sk-ant-test", apiType: "anthropic", compatProfile: "anthropic" }, + }, + }, + }) + const cfg = await Config.load() + expect(cfg.cli?.model).toBe("anthropic/claude-sonnet-4") + expect(cfg.cli?.defaultProvider).toBe("anthropic") + expect(cfg.cli?.providers?.anthropic?.apiKey).toBe("sk-ant-test") + }) + + // --- Deep merge --- + + test("deep merge preserves other sections when saving one", async () => { + await Config.save({ auth: { apiKey: "cbk_first" } }) + await Config.save({ cli: { model: "gpt-5.2" } }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBe("cbk_first") + expect(cfg.cli?.model).toBe("gpt-5.2") + }) + + test("deep merge preserves nested fields within same section", async () => { + await Config.save({ auth: { apiKey: "cbk_key", userId: "u1" } }) + await Config.save({ auth: { activeAgent: "agent1" } }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBe("cbk_key") + expect(cfg.auth?.userId).toBe("u1") + expect(cfg.auth?.activeAgent).toBe("agent1") + }) + + test("undefined deletes fields (for Auth.remove)", async () => { + await Config.save({ auth: { apiKey: "cbk_key", userId: "u1", activeAgent: "a1" } }) + await Config.save({ auth: { apiKey: undefined, userId: undefined, activeAgent: undefined } }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBeUndefined() + expect(cfg.auth?.userId).toBeUndefined() + expect(cfg.auth?.activeAgent).toBeUndefined() + }) + + test("undefined in auth does not affect cli", async () => { + await Config.save({ auth: { apiKey: "cbk_key" }, cli: { model: "gpt-5.2" } }) + await Config.save({ auth: { apiKey: undefined } }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBeUndefined() + expect(cfg.cli?.model).toBe("gpt-5.2") + }) + + // --- Auth proxy --- + + test("Auth.set writes to config.auth", async () => { + await Auth.set({ type: "apikey", value: "cbk_authtest", username: "testuser" }) + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBe("cbk_authtest") + expect(cfg.auth?.username).toBe("testuser") + }) + + test("Auth.get reads from config.auth", async () => { + await Config.save({ auth: { apiKey: "cbk_gettest", username: "user2" } }) + const token = await Auth.get() + expect(token).not.toBeNull() + expect(token!.type).toBe("apikey") + expect(token!.value).toBe("cbk_gettest") + expect(token!.username).toBe("user2") + }) + + test("Auth.get returns null when no apiKey", async () => { + const token = await Auth.get() + expect(token).toBeNull() + }) + + test("Auth.remove clears auth but preserves cli", async () => { + await Config.save({ auth: { apiKey: "cbk_rm", userId: "u1" }, cli: { model: "gpt-5.2" } }) + await Auth.remove() + const cfg = await Config.load() + expect(cfg.auth?.apiKey).toBeUndefined() + expect(cfg.auth?.userId).toBeUndefined() + expect(cfg.cli?.model).toBe("gpt-5.2") + }) + + test("Auth.authenticated returns true/false correctly", async () => { + expect(await Auth.authenticated()).toBe(false) + await Auth.set({ type: "apikey", value: "cbk_auth" }) + expect(await Auth.authenticated()).toBe(true) + await Auth.remove() + expect(await Auth.authenticated()).toBe(false) + }) + + test("Auth.header returns correct Authorization header", async () => { + await Auth.set({ type: "apikey", value: "cbk_hdr" }) + const header = await Auth.header() + expect(header).toEqual({ Authorization: "Bearer cbk_hdr" }) + }) + + test("Auth.header returns empty object when not authenticated", async () => { + const header = await Auth.header() + expect(header).toEqual({}) + }) + + // --- Config helper functions --- + + test("Config.url reads serverUrl", async () => { + await Config.save({ serverUrl: "https://custom.codeblog.ai" }) + const url = await Config.url() + expect(url).toBe("https://custom.codeblog.ai") + }) + + test("Config.url returns default when not set", async () => { + const url = await Config.url() + expect(url).toBe("https://codeblog.ai") + }) + + test("Config.key reads auth.apiKey", async () => { + await Config.save({ auth: { apiKey: "cbk_keytest" } }) + const key = await Config.key() + expect(key).toBe("cbk_keytest") + }) + + test("Config.getActiveAgent reads auth.activeAgent", async () => { + await Config.save({ auth: { activeAgent: "my-bot" } }) + const agent = await Config.getActiveAgent() + expect(agent).toBe("my-bot") + }) + + test("Config.saveActiveAgent writes auth.activeAgent", async () => { + await Config.saveActiveAgent("new-bot") + const cfg = await Config.load() + expect(cfg.auth?.activeAgent).toBe("new-bot") + }) + + test("Config.clearActiveAgent removes auth.activeAgent", async () => { + await Config.save({ auth: { activeAgent: "old-bot", apiKey: "cbk_keep" } }) + await Config.clearActiveAgent() + const cfg = await Config.load() + expect(cfg.auth?.activeAgent).toBeUndefined() + expect(cfg.auth?.apiKey).toBe("cbk_keep") + }) + + test("Config.dailyReportHour returns saved value or default 22", async () => { + expect(await Config.dailyReportHour()).toBe(22) + await Config.save({ dailyReportHour: 8 }) + expect(await Config.dailyReportHour()).toBe(8) + }) + + // --- JSON file format --- + + test("config file is valid JSON with correct structure", async () => { + await Config.save({ + serverUrl: "https://codeblog.ai", + dailyReportHour: 22, + auth: { apiKey: "cbk_json", activeAgent: "bot", userId: "u1" }, + cli: { model: "gpt-5.2", providers: { openai: { apiKey: "sk-test" } } }, + }) + const raw = await fs.readFile(configFile, "utf-8") + const parsed = JSON.parse(raw) + expect(parsed.serverUrl).toBe("https://codeblog.ai") + expect(parsed.dailyReportHour).toBe(22) + expect(parsed.auth.apiKey).toBe("cbk_json") + expect(parsed.cli.model).toBe("gpt-5.2") + expect(parsed.cli.providers.openai.apiKey).toBe("sk-test") + // No old flat fields + expect(parsed.api_url).toBeUndefined() + expect(parsed.apiKey).toBeUndefined() + expect(parsed.providers).toBeUndefined() + }) +}) diff --git a/packages/codeblog/src/config/index.ts b/packages/codeblog/src/config/index.ts index ac2f34f..837846a 100644 --- a/packages/codeblog/src/config/index.ts +++ b/packages/codeblog/src/config/index.ts @@ -5,105 +5,111 @@ import { Global } from "../global" const CONFIG_FILE = path.join(Global.Path.config, "config.json") export namespace Config { - export type ModelApi = "anthropic" | "openai" | "google" | "openai-compatible" + export type ApiType = "anthropic" | "openai" | "google" | "openai-compatible" export type CompatProfile = "anthropic" | "openai" | "openai-compatible" | "google" - export interface FeatureFlags { - ai_provider_registry_v2?: boolean - ai_onboarding_wizard_v2?: boolean + export interface AuthConfig { + apiKey?: string + activeAgent?: string + userId?: string + username?: string } export interface ProviderConfig { - api_key: string - base_url?: string - api?: ModelApi - compat_profile?: CompatProfile + apiKey: string + baseUrl?: string + apiType?: ApiType + compatProfile?: CompatProfile } - export interface CodeblogConfig { - api_url: string - api_key?: string - token?: string + export interface FeatureFlags { + aiProviderRegistryV2?: boolean + aiOnboardingWizardV2?: boolean + } + + export interface CliConfig { model?: string - default_provider?: string - default_language?: string - activeAgent?: string - active_agents?: Record + defaultProvider?: string + defaultLanguage?: string + dailyReportHour?: number providers?: Record - feature_flags?: FeatureFlags + featureFlags?: FeatureFlags + } + + export interface CodeblogConfig { + serverUrl?: string dailyReportHour?: number + auth?: AuthConfig + cli?: CliConfig } const defaults: CodeblogConfig = { - api_url: "https://codeblog.ai", + serverUrl: "https://codeblog.ai", } export const filepath = CONFIG_FILE const FEATURE_FLAG_ENV: Record = { - ai_provider_registry_v2: "CODEBLOG_AI_PROVIDER_REGISTRY_V2", - ai_onboarding_wizard_v2: "CODEBLOG_AI_ONBOARDING_WIZARD_V2", + aiProviderRegistryV2: "CODEBLOG_AI_PROVIDER_REGISTRY_V2", + aiOnboardingWizardV2: "CODEBLOG_AI_ONBOARDING_WIZARD_V2", + } + + function deepMerge(target: Record, source: Record): Record { + const result = { ...target } + for (const key of Object.keys(source)) { + const val = source[key] + if (val === undefined) { + delete result[key] + } else if (typeof val === "object" && !Array.isArray(val) && val !== null) { + result[key] = deepMerge((result[key] as Record) || {}, val) + } else { + result[key] = val + } + } + return result } export async function load(): Promise { const file = Bun.file(CONFIG_FILE) const data = await file.json().catch(() => ({})) - return { ...defaults, ...data } + return deepMerge(defaults, data) as CodeblogConfig } export async function save(config: Partial) { const current = await load() - const merged = { ...current, ...config } + const merged = deepMerge(current, config as Record) await writeFile(CONFIG_FILE, JSON.stringify(merged, null, 2)) await chmod(CONFIG_FILE, 0o600).catch(() => {}) } - export async function getActiveAgent(username?: string) { + // --- Auth helpers --- + + export async function getActiveAgent(_username?: string) { const cfg = await load() - if (username) return cfg.active_agents?.[username] || "" - return cfg.activeAgent || "" + return cfg.auth?.activeAgent || "" } - export async function saveActiveAgent(agent: string, username?: string) { + export async function saveActiveAgent(agent: string, _username?: string) { if (!agent.trim()) return - if (!username) { - await save({ activeAgent: agent }) - return - } - const cfg = await load() - await save({ - active_agents: { - ...(cfg.active_agents || {}), - [username]: agent, - }, - }) - } - - export async function clearActiveAgent(username?: string) { - if (!username) { - await save({ activeAgent: "", active_agents: {} }) - return - } - const cfg = await load() - const map = { ...(cfg.active_agents || {}) } - delete map[username] - await save({ active_agents: map }) + await save({ auth: { activeAgent: agent } }) } - export async function url() { - return process.env.CODEBLOG_URL || (await load()).api_url || "https://codeblog.ai" + export async function clearActiveAgent(_username?: string) { + await save({ auth: { activeAgent: undefined } }) } - export async function key() { - return process.env.CODEBLOG_API_KEY || (await load()).api_key || "" + // --- Server helpers --- + + export async function url() { + return process.env.CODEBLOG_URL || (await load()).serverUrl || "https://codeblog.ai" } - export async function token() { - return process.env.CODEBLOG_TOKEN || (await load()).token || "" + export async function key() { + return process.env.CODEBLOG_API_KEY || (await load()).auth?.apiKey || "" } export async function language() { - return process.env.CODEBLOG_LANGUAGE || (await load()).default_language + return process.env.CODEBLOG_LANGUAGE || (await load()).cli?.defaultLanguage } export async function dailyReportHour(): Promise { @@ -111,6 +117,8 @@ export namespace Config { return val !== undefined ? val : 22 } + // --- Feature flags --- + function parseBool(raw: string | undefined): boolean | undefined { if (!raw) return undefined const v = raw.trim().toLowerCase() @@ -126,6 +134,6 @@ export namespace Config { export async function featureEnabled(flag: keyof FeatureFlags): Promise { const env = parseBool(process.env[FEATURE_FLAG_ENV[flag]]) if (env !== undefined) return env - return !!(await load()).feature_flags?.[flag] + return !!(await load()).cli?.featureFlags?.[flag] } } diff --git a/packages/codeblog/src/global/index.ts b/packages/codeblog/src/global/index.ts index 3b1a1be..7c4da9e 100644 --- a/packages/codeblog/src/global/index.ts +++ b/packages/codeblog/src/global/index.ts @@ -12,7 +12,7 @@ const localappdata = process.env.LOCALAPPDATA || path.join(home, "AppData", "Loc const data = win ? path.join(localappdata, app) : path.join(xdgData || path.join(home, ".local", "share"), app) const cache = win ? path.join(localappdata, app, "cache") : path.join(xdgCache || path.join(home, ".cache"), app) -const config = win ? path.join(appdata, app) : path.join(xdgConfig || path.join(home, ".config"), app) +const config = path.join(home, `.${app}`) const state = win ? path.join(localappdata, app, "state") : path.join(xdgState || path.join(home, ".local", "state"), app) export namespace Global { diff --git a/packages/codeblog/src/tui/app.tsx b/packages/codeblog/src/tui/app.tsx index b883b50..22b83f6 100644 --- a/packages/codeblog/src/tui/app.tsx +++ b/packages/codeblog/src/tui/app.tsx @@ -195,13 +195,13 @@ function App() { const { Config } = await import("../config") const cfg = await Config.load() const model = resolveModelFromConfig(cfg) || AIProvider.DEFAULT_MODEL - if (cfg.model !== model) await Config.save({ model }) + if (cfg.cli?.model !== model) await Config.save({ cli: { model } }) setModelName(model) const info = AIProvider.BUILTIN_MODELS[model] setAiProvider(info?.providerID || model.split("/")[0] || "ai") // Fetch credit balance if using codeblog provider - if (cfg.default_provider === "codeblog") { + if (cfg.cli?.defaultProvider === "codeblog") { try { const { fetchCreditBalance } = await import("../ai/codeblog-provider") const balance = await fetchCreditBalance() diff --git a/packages/codeblog/src/tui/commands.ts b/packages/codeblog/src/tui/commands.ts index bbafefc..b7e0ffd 100644 --- a/packages/codeblog/src/tui/commands.ts +++ b/packages/codeblog/src/tui/commands.ts @@ -56,7 +56,7 @@ export function createCommands(deps: CommandDeps): CmdDef[] { } const saveId = picked.providerID === "openai-compatible" ? `openai-compatible/${picked.id}` : picked.id - await Config.save({ model: saveId }) + await Config.save({ cli: { model: saveId } }) deps.onAIConfigured() deps.showMsg(`Model switched to ${saveId}`, deps.colors.success) }}, @@ -72,7 +72,11 @@ export function createCommands(deps: CommandDeps): CmdDef[] { { name: "/logout", description: "Sign out of CodeBlog", action: async () => { try { const { Auth } = await import("../auth") + const { McpBridge } = await import("../mcp/client") + const { clearChatToolsCache } = await import("../ai/tools") await Auth.remove() + await McpBridge.disconnect() + clearChatToolsCache() deps.showMsg("Logged out.", deps.colors.text) deps.onLogout() } catch (err) { deps.showMsg(`Logout failed: ${err instanceof Error ? err.message : String(err)}`, deps.colors.error) } diff --git a/packages/codeblog/src/tui/routes/home.tsx b/packages/codeblog/src/tui/routes/home.tsx index 06d00aa..d4a76e9 100644 --- a/packages/codeblog/src/tui/routes/home.tsx +++ b/packages/codeblog/src/tui/routes/home.tsx @@ -306,13 +306,13 @@ export function Home(props: { const current = resolveModelFromConfig(cfg) || AIProvider.DEFAULT_MODEL const currentBuiltin = AIProvider.BUILTIN_MODELS[current] const currentProvider = - cfg.default_provider || + cfg.cli?.defaultProvider || (current.includes("/") ? current.split("/")[0] : currentBuiltin?.providerID) || "openai" - const providerCfg = cfg.providers?.[currentProvider] - const providerApi = providerCfg?.api || providerCfg?.compat_profile || (currentProvider === "openai" ? "openai" : "openai-compatible") - const providerKey = providerCfg?.api_key - const providerBase = providerCfg?.base_url || (currentProvider === "openai" ? "https://api.openai.com" : "") + const providerCfg = cfg.cli?.providers?.[currentProvider] + const providerApi = providerCfg?.apiType || providerCfg?.compatProfile || (currentProvider === "openai" ? "openai" : "openai-compatible") + const providerKey = providerCfg?.apiKey + const providerBase = providerCfg?.baseUrl || (currentProvider === "openai" ? "https://api.openai.com" : "") const remote = await (async () => { if (!providerKey || !providerBase) return [] as string[] @@ -382,7 +382,7 @@ export function Home(props: { async function pickModel(id: string) { try { const { Config } = await import("../../config") - await Config.save({ model: id }) + await Config.save({ cli: { model: id } }) props.onAIConfigured() showMsg(`Set model to ${id}`, theme.colors.success) } catch (err) { @@ -958,18 +958,20 @@ export function Home(props: { const proxyURL = `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat` const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} providers["codeblog"] = { - api_key: "proxy", - base_url: proxyURL, - api: "openai-compatible", - compat_profile: "openai-compatible", + apiKey: "proxy", + baseUrl: proxyURL, + apiType: "openai-compatible", + compatProfile: "openai-compatible", } await Config.save({ - providers, - default_provider: "codeblog", - model: `codeblog/${balance.model}`, + cli: { + providers, + defaultProvider: "codeblog", + model: `codeblog/${balance.model}`, + }, }) const msg = claim.already_claimed @@ -1006,17 +1008,17 @@ export function Home(props: { const { Config } = await import("../../config") const cfg = await Config.load() - const providers = cfg.providers || {} + const providers = cfg.cli?.providers || {} const resolvedApi = verify.detectedApi || choice.api const resolvedCompat = choice.providerID === "openai-compatible" && resolvedApi === "openai" ? "openai-compatible" as const : resolvedApi - const providerConfig: { api_key: string; base_url?: string; api: typeof resolvedApi; compat_profile: typeof resolvedCompat } = { - api_key: key, - api: resolvedApi, - compat_profile: resolvedCompat, + const providerConfig: { apiKey: string; baseUrl?: string; apiType: typeof resolvedApi; compatProfile: typeof resolvedCompat } = { + apiKey: key, + apiType: resolvedApi, + compatProfile: resolvedCompat, } - if (baseURL) providerConfig.base_url = baseURL + if (baseURL) providerConfig.baseUrl = baseURL providers[choice.providerID] = providerConfig const saveModel = choice.providerID === "openai-compatible" && !model.includes("/") @@ -1024,9 +1026,11 @@ export function Home(props: { : model await Config.save({ - providers, - default_provider: choice.providerID, - model: saveModel, + cli: { + providers, + defaultProvider: choice.providerID, + model: saveModel, + }, }) showMsg(`✓ AI configured: ${choice.name} (${saveModel})`, theme.colors.success) diff --git a/packages/codeblog/src/tui/routes/model.tsx b/packages/codeblog/src/tui/routes/model.tsx index c250eeb..556d153 100644 --- a/packages/codeblog/src/tui/routes/model.tsx +++ b/packages/codeblog/src/tui/routes/model.tsx @@ -46,7 +46,7 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) { const cfg = await Config.load() const resolved = resolveModelFromConfig(cfg) || AIProvider.DEFAULT_MODEL setCurrent(resolved) - if (cfg.model !== resolved) await Config.save({ model: resolved }) + if (cfg.cli?.model !== resolved) await Config.save({ cli: { model: resolved } }) setStatus("Fetching models from API...") const all = await AIProvider.available() @@ -130,7 +130,7 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) { const item = filtered().find((m) => m.id === id) const saveId = item && item.provider === "openai-compatible" ? `openai-compatible/${id}` : id const { Config } = await import("../../config") - await Config.save({ model: saveId }) + await Config.save({ cli: { model: saveId } }) props.onDone(saveId) } catch { props.onDone() From 0a70ce3683d89b416d4d958065263d4af445199c Mon Sep 17 00:00:00 2001 From: Kadxy <135022988+Kadxy@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:19:01 +0800 Subject: [PATCH 2/3] fix: remove Config.CliConfig type assertions in configure.ts Config is dynamically imported at runtime, so the namespace isn't available for type assertions at compile time. The casts were unnecessary since Config.save accepts Partial. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/codeblog/src/ai/configure.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/codeblog/src/ai/configure.ts b/packages/codeblog/src/ai/configure.ts index 4307dff..815366b 100644 --- a/packages/codeblog/src/ai/configure.ts +++ b/packages/codeblog/src/ai/configure.ts @@ -112,7 +112,7 @@ export async function saveProvider(url: string, key: string): Promise<{ provider } } - await Config.save({ cli: cliUpdate as Config.CliConfig }) + await Config.save({ cli: cliUpdate }) return { provider: `${detected} format` } } @@ -134,7 +134,7 @@ export async function saveProvider(url: string, key: string): Promise<{ provider cliUpdate2.model = defaultModelForProvider(provider) } - await Config.save({ cli: cliUpdate2 as Config.CliConfig }) + await Config.save({ cli: cliUpdate2 }) return { provider } } From 89d757702dd3f35eaa37f12c249178a8a8460c46 Mon Sep 17 00:00:00 2001 From: Kadxy <135022988+Kadxy@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:24:47 +0800 Subject: [PATCH 3/3] feat: add JSON Schema for ~/.codeblog/config.json Provides autocomplete and validation in VS Code / Cursor when editing the config file. Reference via "$schema" field: { "$schema": "https://codeblog.ai/schemas/config.json", ... } Co-Authored-By: Claude Opus 4.6 (1M context) --- schemas/config.schema.json | 114 +++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 schemas/config.schema.json diff --git a/schemas/config.schema.json b/schemas/config.schema.json new file mode 100644 index 0000000..1d667fb --- /dev/null +++ b/schemas/config.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://codeblog.ai/schemas/config.json", + "title": "CodeBlog Config", + "description": "Configuration for CodeBlog CLI/TUI and MCP server (~/.codeblog/config.json)", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "serverUrl": { + "type": "string", + "format": "uri", + "default": "https://codeblog.ai", + "description": "CodeBlog server URL" + }, + "dailyReportHour": { + "type": "integer", + "minimum": -1, + "maximum": 23, + "default": 22, + "description": "Hour (0-23) to auto-trigger daily report. -1 to disable." + }, + "auth": { + "type": "object", + "description": "Authentication (shared between CLI and MCP)", + "properties": { + "apiKey": { + "type": "string", + "pattern": "^cbk_", + "description": "Agent API key (starts with cbk_)" + }, + "activeAgent": { + "type": "string", + "description": "Currently active agent name" + }, + "userId": { + "type": "string", + "description": "Backend user ID" + }, + "username": { + "type": "string", + "description": "Username" + } + }, + "additionalProperties": false + }, + "cli": { + "type": "object", + "description": "CLI/TUI-only settings (MCP ignores these)", + "properties": { + "model": { + "type": "string", + "description": "Default AI model ID (e.g. 'claude-sonnet-4-20250514', 'openai-compatible/gpt-5.2')" + }, + "defaultProvider": { + "type": "string", + "description": "Default AI provider ID (e.g. 'anthropic', 'openai', 'openai-compatible')" + }, + "defaultLanguage": { + "type": "string", + "description": "Default content language for posts (BCP 47, e.g. 'en', 'zh', 'ja')" + }, + "providers": { + "type": "object", + "description": "AI provider configurations", + "additionalProperties": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "description": "Provider API key" + }, + "baseUrl": { + "type": "string", + "format": "uri", + "description": "Provider base URL (for proxies or custom endpoints)" + }, + "apiType": { + "type": "string", + "enum": ["anthropic", "openai", "google", "openai-compatible"], + "description": "API wire protocol" + }, + "compatProfile": { + "type": "string", + "enum": ["anthropic", "openai", "google", "openai-compatible"], + "description": "Compatibility profile for request patching" + } + }, + "required": ["apiKey"] + } + }, + "featureFlags": { + "type": "object", + "description": "Feature flags", + "properties": { + "aiProviderRegistryV2": { + "type": "boolean", + "description": "Enable AI provider registry v2" + }, + "aiOnboardingWizardV2": { + "type": "boolean", + "description": "Enable AI onboarding wizard v2" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +}