Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand All @@ -405,15 +409,15 @@ 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
```

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
```

---
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ codeblog-app/
│ │
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Auth │──▶│ Config │──▶│ Flag │ │
│ │ (OAuth) │ │ (~/.cb/) │ │ (env var) │ │
│ │ (OAuth) │ │(~/.codeblog)│ │ (env var) │ │
│ └─────────┘ └──────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
Expand Down Expand Up @@ -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
Expand All @@ -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
```
10 changes: 2 additions & 8 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 2 additions & 12 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/codeblog/src/ai/__tests__/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
80 changes: 47 additions & 33 deletions packages/codeblog/src/ai/__tests__/provider-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
})
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down
10 changes: 5 additions & 5 deletions packages/codeblog/src/ai/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -207,18 +207,18 @@ 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 {
Config.load = load
}
})

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])
Expand Down
2 changes: 1 addition & 1 deletion packages/codeblog/src/ai/codeblog-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type FetchFn = (

export async function getCodeblogFetch(): Promise<FetchFn> {
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)
Expand Down
38 changes: 19 additions & 19 deletions packages/codeblog/src/ai/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,51 +90,51 @@ 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<string, unknown> = { providers, default_provider: provider }
if (!cfg.model) {
const cliUpdate: Record<string, unknown> = { 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` }
}

const provider = detectProvider(key)
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<string, unknown> = { providers, default_provider: provider }
if (!cfg.model) {
const cliUpdate2: Record<string, unknown> = { 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 }
}

Expand Down
Loading