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
18 changes: 14 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -104,6 +108,10 @@ function listForProvider(
}

async function main(): Promise<number> {
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).
Expand Down Expand Up @@ -133,6 +141,7 @@ async function main(): Promise<number> {
}

const { values } = parsed;
const config = loadConfig();

if (values.help) {
console.log(HELP);
Expand All @@ -144,7 +153,7 @@ async function main(): Promise<number> {
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(
Expand All @@ -154,8 +163,8 @@ async function main(): Promise<number> {
}

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(
Expand Down Expand Up @@ -207,6 +216,7 @@ async function main(): Promise<number> {
if (!model && !resuming) {
model =
process.env.CLAUDELY_MODEL ??
config.model ??
(provider.modelEnvVar ? process.env[provider.modelEnvVar] : undefined);

if (!model) {
Expand Down
37 changes: 1 addition & 36 deletions src/compat.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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 [
Expand Down
13 changes: 0 additions & 13 deletions src/compat.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
import { readFileSync } from "node:fs";

export interface ApplyContext {
settings: Record<string, unknown> | undefined;
baseUrl: string;
existingClaudeArgs: readonly string[];
}

export function loadSettings(settingsPath: string): Record<string, unknown> | undefined {
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}

export interface CompatResult {
warnings: string[];
extraArgs: string[];
Expand Down
193 changes: 193 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
52 changes: 52 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined {
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
Loading
Loading