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..815366b 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 }) 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 }) 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() 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 +}