From 4ebf763df23af81b653336bd3a31b10d736c18d5 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:10:08 -0700 Subject: [PATCH 1/6] refactor: move loadSettings from compat.ts to config.ts Decouples config I/O from compat business logic. config.ts now owns all disk reads (claudely config + claude settings). compat.ts keeps only applyCompat and the incompatibility table. --- src/cli.ts | 3 ++- src/compat.test.ts | 3 ++- src/compat.ts | 13 ------------ src/config.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 src/config.ts diff --git a/src/cli.ts b/src/cli.ts index 0d634c5..eb0213b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,8 @@ import { listV1Models, type ModelEntry, } from "./listers.js"; -import { applyCompat, loadSettings } from "./compat.js"; +import { applyCompat } from "./compat.js"; +import { loadSettings } from "./config.js"; import { splitArgs, type FlagSpec } from "./argsplit.js"; import { renderVersion } from "./version.js"; import { diff --git a/src/compat.test.ts b/src/compat.test.ts index 17d5714..95aee76 100644 --- a/src/compat.test.ts +++ b/src/compat.test.ts @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { applyCompat, loadSettings, type Incompatibility } from "./compat.js"; +import { applyCompat, type Incompatibility } from "./compat.js"; +import { loadSettings } from "./config.js"; test("effortLevel xhigh against non-Anthropic: warning + ['--effort','high']", () => { const result = applyCompat({ diff --git a/src/compat.ts b/src/compat.ts index cf0993a..c5db781 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -1,22 +1,9 @@ -import { readFileSync } from "node:fs"; - export interface ApplyContext { settings: Record | undefined; baseUrl: string; existingClaudeArgs: readonly string[]; } -export function loadSettings(settingsPath: string): Record | undefined { - try { - const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } -} - export interface CompatResult { warnings: string[]; extraArgs: string[]; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f21d6b4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,52 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export interface ClaudelyConfig { + provider?: string; + baseUrl?: string; + token?: string; + model?: string; +} + +export function configDir(): string { + switch (process.platform) { + case "win32": + return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "claudely"); + case "darwin": + return join(homedir(), "Library", "Application Support", "claudely"); + default: + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "claudely"); + } +} + +export function configPath(): string { + return join(configDir(), "config.json"); +} + +export function loadConfig(): ClaudelyConfig { + try { + return JSON.parse(readFileSync(configPath(), "utf8")) as ClaudelyConfig; + } catch (err) { + if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}; + process.stderr.write("claudely: warning: corrupt config file, using defaults\n"); + return {}; + } +} + +export function saveConfig(config: ClaudelyConfig): void { + const dir = configDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n"); +} + +export function loadSettings(settingsPath: string): Record | undefined { + try { + const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} From 7fbfc1c64ea0758458b2625342bd20b2d92b39f8 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:14:18 -0700 Subject: [PATCH 2/6] test: add config.ts unit tests and move loadSettings tests from compat --- src/compat.test.ts | 36 --------- src/config.test.ts | 193 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 src/config.test.ts diff --git a/src/compat.test.ts b/src/compat.test.ts index 95aee76..4fe2d89 100644 --- a/src/compat.test.ts +++ b/src/compat.test.ts @@ -1,10 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; import { applyCompat, type Incompatibility } from "./compat.js"; -import { loadSettings } from "./config.js"; test("effortLevel xhigh against non-Anthropic: warning + ['--effort','high']", () => { const result = applyCompat({ @@ -63,38 +59,6 @@ test("undefined settings (missing/malformed file): no-op", () => { ); }); -test("loadSettings returns parsed object for a valid file", () => { - const dir = mkdtempSync(join(tmpdir(), "claudely-test-")); - try { - const path = join(dir, "settings.json"); - writeFileSync(path, JSON.stringify({ effortLevel: "xhigh", other: 1 })); - assert.deepEqual(loadSettings(path), { effortLevel: "xhigh", other: 1 }); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test("loadSettings returns undefined for missing, malformed, or non-object JSON", () => { - const dir = mkdtempSync(join(tmpdir(), "claudely-test-")); - try { - assert.equal(loadSettings(join(dir, "nope.json")), undefined); - - const bad = join(dir, "bad.json"); - writeFileSync(bad, "{ not valid json"); - assert.equal(loadSettings(bad), undefined); - - const arr = join(dir, "arr.json"); - writeFileSync(arr, "[1,2,3]"); - assert.equal(loadSettings(arr), undefined); - - const lit = join(dir, "lit.json"); - writeFileSync(lit, '"a string"'); - assert.equal(loadSettings(lit), undefined); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - test("safe / unset / unknown setting values: no-op", () => { const empty = { warnings: [], extraArgs: [] }; for (const settings of [ diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..549ef5e --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,193 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { configDir, configPath, loadConfig, saveConfig, loadSettings } from "./config.js"; + +// --- platform mocking helpers --- +const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +function mockPlatform(value: string) { + Object.defineProperty(process, "platform", { value, configurable: true }); +} +function restorePlatform() { + Object.defineProperty(process, "platform", originalPlatform); +} + +// --- configDir platform tests --- + +test("configDir on linux uses $XDG_CONFIG_HOME/claudely when set", () => { + const saved = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + process.env.XDG_CONFIG_HOME = "/tmp/custom-xdg"; + assert.equal(configDir(), "/tmp/custom-xdg/claudely"); + } finally { + restorePlatform(); + if (saved === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = saved; + } +}); + +test("configDir on linux defaults to ~/.config/claudely when XDG unset", () => { + const saved = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + delete process.env.XDG_CONFIG_HOME; + assert.equal(configDir(), join(homedir(), ".config", "claudely")); + } finally { + restorePlatform(); + if (saved === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = saved; + } +}); + +test("configDir on darwin uses Library/Application Support/claudely", () => { + try { + mockPlatform("darwin"); + assert.equal(configDir(), join(homedir(), "Library", "Application Support", "claudely")); + } finally { + restorePlatform(); + } +}); + +test("configDir on win32 uses APPDATA/claudely", () => { + const saved = process.env.APPDATA; + try { + mockPlatform("win32"); + process.env.APPDATA = "C:\\Users\\test\\AppData\\Roaming"; + assert.equal(configDir(), join("C:\\Users\\test\\AppData\\Roaming", "claudely")); + } finally { + restorePlatform(); + if (saved === undefined) delete process.env.APPDATA; + else process.env.APPDATA = saved; + } +}); + +test("configPath ends with config.json", () => { + assert.ok(configPath().endsWith("config.json")); +}); + +// --- loadConfig / saveConfig tests --- + +test("round-trip: saveConfig then loadConfig returns same object", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-cfg-")); + const savedXDG = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + process.env.XDG_CONFIG_HOME = dir; + const config = { provider: "ollama", baseUrl: "http://localhost:11434", model: "llama3" }; + saveConfig(config); + const loaded = loadConfig(); + assert.deepEqual(loaded, config); + } finally { + restorePlatform(); + if (savedXDG === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXDG; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadConfig returns {} for missing file", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-cfg-")); + const savedXDG = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + process.env.XDG_CONFIG_HOME = dir; + // No config file exists in this temp dir + assert.deepEqual(loadConfig(), {}); + } finally { + restorePlatform(); + if (savedXDG === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXDG; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadConfig returns {} and writes warning to stderr for corrupt JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-cfg-")); + const savedXDG = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + process.env.XDG_CONFIG_HOME = dir; + + // Create the claudely subdirectory and write corrupt JSON + const cfgDir = join(dir, "claudely"); + mkdirSync(cfgDir, { recursive: true }); + writeFileSync(join(cfgDir, "config.json"), "{ not valid json !!!"); + + // Capture stderr + const chunks: string[] = []; + const origWrite = process.stderr.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }) as typeof process.stderr.write; + + try { + const result = loadConfig(); + assert.deepEqual(result, {}); + assert.ok(chunks.some((c) => c.includes("corrupt")), "expected stderr warning about corrupt config"); + } finally { + process.stderr.write = origWrite; + } + } finally { + restorePlatform(); + if (savedXDG === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXDG; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveConfig creates parent directories if missing", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-cfg-")); + const savedXDG = process.env.XDG_CONFIG_HOME; + try { + mockPlatform("linux"); + // Point XDG to a non-existent subdirectory + const nested = join(dir, "deeply", "nested"); + process.env.XDG_CONFIG_HOME = nested; + assert.ok(!existsSync(nested)); + saveConfig({ provider: "test" }); + assert.ok(existsSync(join(nested, "claudely", "config.json"))); + } finally { + restorePlatform(); + if (savedXDG === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXDG; + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- loadSettings tests (moved from compat.test.ts) --- + +test("loadSettings returns parsed object for a valid file", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-test-")); + try { + const path = join(dir, "settings.json"); + writeFileSync(path, JSON.stringify({ effortLevel: "xhigh", other: 1 })); + assert.deepEqual(loadSettings(path), { effortLevel: "xhigh", other: 1 }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadSettings returns undefined for missing, malformed, or non-object JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "claudely-test-")); + try { + assert.equal(loadSettings(join(dir, "nope.json")), undefined); + + const bad = join(dir, "bad.json"); + writeFileSync(bad, "{ not valid json"); + assert.equal(loadSettings(bad), undefined); + + const arr = join(dir, "arr.json"); + writeFileSync(arr, "[1,2,3]"); + assert.equal(loadSettings(arr), undefined); + + const lit = join(dir, "lit.json"); + writeFileSync(lit, '"a string"'); + assert.equal(loadSettings(lit), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 217a25ae7815e1ecead3697f580d84685a491fa3 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:15:45 -0700 Subject: [PATCH 3/6] feat: thread persistent config into CLI resolution chain loadConfig() now participates in provider/baseUrl/token/model resolution. Precedence: CLI flag > env var > config file > provider default. --- src/cli.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index eb0213b..98236d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,7 @@ import { type ModelEntry, } from "./listers.js"; import { applyCompat } from "./compat.js"; -import { loadSettings } from "./config.js"; +import { loadSettings, loadConfig } from "./config.js"; import { splitArgs, type FlagSpec } from "./argsplit.js"; import { renderVersion } from "./version.js"; import { @@ -134,6 +134,7 @@ async function main(): Promise { } const { values } = parsed; + const config = loadConfig(); if (values.help) { console.log(HELP); @@ -145,7 +146,7 @@ async function main(): Promise { return 0; } - const providerName = values.provider ?? process.env.CLAUDELY_PROVIDER ?? "lmstudio"; + const providerName = values.provider ?? process.env.CLAUDELY_PROVIDER ?? config.provider ?? "lmstudio"; const provider = PROVIDERS[providerName]; if (!provider) { console.error( @@ -155,8 +156,8 @@ async function main(): Promise { } const baseUrl = - values["base-url"] ?? process.env.CLAUDELY_BASE_URL ?? provider.defaultBaseUrl(); - const token = values.token ?? process.env.CLAUDELY_TOKEN ?? provider.defaultToken; + values["base-url"] ?? process.env.CLAUDELY_BASE_URL ?? config.baseUrl ?? provider.defaultBaseUrl(); + const token = values.token ?? process.env.CLAUDELY_TOKEN ?? config.token ?? provider.defaultToken; if (!baseUrl) { console.error( @@ -208,6 +209,7 @@ async function main(): Promise { if (!model && !resuming) { model = process.env.CLAUDELY_MODEL ?? + config.model ?? (provider.modelEnvVar ? process.env[provider.modelEnvVar] : undefined); if (!model) { From ae67db65ca4808f958c7d3a3d31fa9115d5e13d6 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:20:34 -0700 Subject: [PATCH 4/6] feat: add claudely setup interactive wizard Walks the user through provider, base URL, token, connection test, and default model selection. Saves to platform-native config file. Re-run pre-fills with saved values. Closes #14 --- src/cli.ts | 7 ++++ src/setup.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/setup.ts diff --git a/src/cli.ts b/src/cli.ts index 98236d3..4972122 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ import { import { applyCompat } from "./compat.js"; import { loadSettings, loadConfig } from "./config.js"; import { splitArgs, type FlagSpec } from "./argsplit.js"; +import { runSetup } from "./setup.js"; import { renderVersion } from "./version.js"; import { isResumeIntent, @@ -57,6 +58,7 @@ claudely options: --new Force a fresh session (skip auto-resume) -h, --help Show this help -V, --version Print claudely and claude versions, then exit + setup Configure provider, URL, token, and default model Bare \`claudely\` (no args, no --model) auto-resumes the most recent claude session for the current directory when one exists. Use --new to @@ -72,6 +74,7 @@ Use \`--\` as an escape hatch to force a token through (e.g. when claude gains a flag whose name collides with one of claudely's own). Examples: + claudely setup # interactive config wizard claudely # LM Studio + interactive picker claudely -p ollama # Ollama claudely -p llamacpp # llama.cpp @@ -105,6 +108,10 @@ function listForProvider( } async function main(): Promise { + if (process.argv[2] === "setup") { + return runSetup(); + } + // Anything claudely doesn't recognize as one of its own flags is forwarded // to claude. Use `--` as an explicit escape hatch to force a token through // (e.g. when claude has a flag that collides with one of ours). diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..7f1bedc --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,94 @@ +import { select, input, confirm } from "@inquirer/prompts"; +import { PROVIDERS, PROVIDER_NAMES, type Provider } from "./providers.js"; +import { listLmStudio, listOllama, listV1Models, type ModelEntry } from "./listers.js"; +import { loadConfig, saveConfig, type ClaudelyConfig } from "./config.js"; + +function listForProvider( + provider: Provider, + baseUrl: string, + token: string, +): Promise { + switch (provider.lister) { + case "lmstudio": + return listLmStudio(baseUrl, token); + case "ollama": + return listOllama(baseUrl, token); + case "v1_models": + return listV1Models(baseUrl, token); + } +} + +export async function runSetup(): Promise { + const existing = loadConfig(); + + const providerName = await select({ + message: "Provider", + choices: PROVIDER_NAMES.map(name => ({ name, value: name })), + default: existing.provider ?? "lmstudio", + }); + + const provider = PROVIDERS[providerName]; + + const baseUrl = await input({ + message: "Base URL", + default: existing.baseUrl ?? provider.defaultBaseUrl(), + }); + + const token = await input({ + message: "Auth token", + default: existing.token ?? provider.defaultToken, + }); + + let models: ModelEntry[] = []; + try { + const res = await fetch(`${baseUrl}/v1/models`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + models = await listForProvider(provider, baseUrl, token); + console.log(` Connected — ${models.length} model(s) found.`); + } else { + console.log(` Server responded ${res.status} — skipping model discovery.`); + } + } catch { + console.log(" Could not reach server — you can still save and connect later."); + } + + let model: string | undefined; + if (models.length > 0) { + const choices = models.map(m => ({ name: m.display, value: m.id })); + choices.push({ name: "(skip — no default model)", value: "" }); + const picked = await select({ + message: "Default model", + choices, + default: existing.model ?? undefined, + }); + if (picked) model = picked; + } else { + const manual = await input({ + message: "Default model (Enter to skip)", + default: existing.model ?? "", + }); + if (manual) model = manual; + } + + const config: ClaudelyConfig = { provider: providerName, baseUrl, token }; + if (model) config.model = model; + + console.log("\nConfig to save:"); + console.log(` provider: ${config.provider}`); + console.log(` baseUrl: ${config.baseUrl}`); + console.log(` token: ${config.token}`); + if (config.model) console.log(` model: ${config.model}`); + console.log(); + + const ok = await confirm({ message: "Save?", default: true }); + if (!ok) { + console.log("Aborted — nothing saved."); + return 0; + } + + saveConfig(config); + console.log("Saved. Run `claudely` to use your new config."); + return 0; +} From 64419477bb62807e7c5d32db0a47043d6e843648 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:28:11 -0700 Subject: [PATCH 5/6] fix: send auth token in setup wizard connection test The connection test was fetching /v1/models without an Authorization header, causing servers with API key auth to return 401 and skip model discovery silently. --- src/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/setup.ts b/src/setup.ts index 7f1bedc..b4fa060 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -42,6 +42,7 @@ export async function runSetup(): Promise { let models: ModelEntry[] = []; try { const res = await fetch(`${baseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5000), }); if (res.ok) { From 11587717166efe4e6cbc19f57da124c0899df6d2 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Sun, 3 May 2026 19:41:43 -0700 Subject: [PATCH 6/6] fix: use auth_token envStyle for llamacpp provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Max users authenticate via OAuth — Claude Code ignores ANTHROPIC_API_KEY and sends its session token instead, which the local server rejects as invalid. Switching llamacpp to auth_token envStyle sets ANTHROPIC_AUTH_TOKEN, which Claude Code uses regardless of the auth mode (Max vs API key). --- src/providers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/providers.ts b/src/providers.ts index bd6960e..ad7dcdf 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -38,10 +38,9 @@ export const PROVIDERS: Record = { llamacpp: { name: "llamacpp", defaultBaseUrl: () => `http://localhost:${process.env.LLAMACPP_PORT ?? "8080"}`, - // llama-server expects the key in ANTHROPIC_API_KEY, per unsloth's docs. defaultToken: "sk-no-key-required", modelEnvVar: "LLAMACPP_MODEL", - envStyle: "api_key", + envStyle: "auth_token", lister: "v1_models", startHint: "llama-server --port 8080 -m /path/to/model.gguf", },