diff --git a/src/cli.ts b/src/cli.ts index 0d634c5..4972122 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,8 +22,10 @@ import { listV1Models, type ModelEntry, } from "./listers.js"; -import { applyCompat, loadSettings } from "./compat.js"; +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, @@ -56,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 @@ -71,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 @@ -104,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). @@ -133,6 +141,7 @@ async function main(): Promise { } const { values } = parsed; + const config = loadConfig(); if (values.help) { console.log(HELP); @@ -144,7 +153,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( @@ -154,8 +163,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( @@ -207,6 +216,7 @@ async function main(): Promise { if (!model && !resuming) { model = process.env.CLAUDELY_MODEL ?? + config.model ?? (provider.modelEnvVar ? process.env[provider.modelEnvVar] : undefined); if (!model) { diff --git a/src/compat.test.ts b/src/compat.test.ts index 17d5714..4fe2d89 100644 --- a/src/compat.test.ts +++ b/src/compat.test.ts @@ -1,9 +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, loadSettings, type Incompatibility } from "./compat.js"; +import { applyCompat, type Incompatibility } from "./compat.js"; test("effortLevel xhigh against non-Anthropic: warning + ['--effort','high']", () => { const result = applyCompat({ @@ -62,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/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.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 }); + } +}); 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; + } +} 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", }, diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..b4fa060 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,95 @@ +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`, { + headers: { Authorization: `Bearer ${token}` }, + 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; +}