Skip to content

Commit 09188ff

Browse files
feat(utils): save and load config
Store Ollama Host & Default Model in a User Config File Introduce a `~/.code-ollama/config.json` file that persists `host` and `model`, with env-var overrides and write-back when the user picks a model via `/model`. Approach: - **Config location:** `~/.code-ollama/config.json` - **Schema:** `{ "host": string, "model": string }` — both optional; missing keys fall back to hardcoded defaults. - **Priority order (read):** env var → config file → hardcoded default. - **Config module:** new `src/utils/config.ts` with two exports: - `loadConfig()` — reads and parses the file synchronously at startup. - `saveConfig(patch)` — merges a partial update and writes the file (creates dir if needed). - **Barrel update:** export the new module through `src/utils/index.ts`. - **`ollama.ts` change:** replace the two `process.env` lines with values from `loadConfig()`. - **`App.tsx` change:** also read initial model from `loadConfig()`; call `saveConfig({ model })` inside `handleSelect` after a model is picked. - **Tests:** `src/utils/config.test.ts` covering load (file present / missing), fallback, env-var override, and save/merge. Files changed: | File | Action | | --- | --- | | `src/utils/config.ts` | **Create** — `loadConfig` + `saveConfig` | | `src/utils/config.test.ts` | **Create** — unit tests | | `src/utils/index.ts` | **Edit** — add `export * as config` | | `src/utils/ollama.ts` | **Edit** — consume `loadConfig()` values | | `src/components/App.tsx` | **Edit** — read initial model from config; write back on select |
1 parent 6113b00 commit 09188ff

6 files changed

Lines changed: 166 additions & 8 deletions

File tree

src/components/App.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import { render } from 'ink-testing-library';
33

44
import { tick } from '../utils/test';
55

6+
vi.mock('../utils', () => ({
7+
config: {
8+
loadConfig: vi.fn(() => ({
9+
host: 'http://localhost:11434',
10+
model: 'gemma4',
11+
})),
12+
saveConfig: vi.fn(),
13+
},
14+
}));
15+
616
const capturedCallbacks = vi.hoisted(() => ({
717
onCommand: null as ((command: string) => void) | null,
818
onSelect: null as ((model: string) => void) | null,

src/components/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Box, Text } from 'ink';
22
import { useCallback, useState } from 'react';
33

4+
import { config } from '../utils';
45
import { Chat } from './Chat';
56
import { ModelPicker } from './ModelPicker';
67

7-
const DEFAULT_MODEL = process.env.OLLAMA_MODEL ?? 'gemma4';
8-
98
export function App() {
10-
const [model, setModel] = useState(DEFAULT_MODEL);
9+
const [model, setModel] = useState(() => config.loadConfig().model);
1110
const [picking, setPicking] = useState(false);
1211

1312
const handleCommand = useCallback((command: string) => {
@@ -18,6 +17,7 @@ export function App() {
1817

1918
const handleSelect = useCallback((selected: string) => {
2019
setModel(selected);
20+
config.saveConfig({ model: selected });
2121
setPicking(false);
2222
}, []);
2323

src/utils/config.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs';
8+
import { homedir } from 'node:os';
9+
import { join } from 'node:path';
10+
11+
const CONFIG_DIR = join(homedir(), '.code-ollama');
12+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
13+
14+
function writeConfig(data: object) {
15+
mkdirSync(CONFIG_DIR, { recursive: true });
16+
writeFileSync(CONFIG_PATH, JSON.stringify(data), 'utf8');
17+
}
18+
19+
function removeConfig() {
20+
if (existsSync(CONFIG_PATH)) {
21+
rmSync(CONFIG_PATH);
22+
}
23+
}
24+
25+
describe('config', () => {
26+
const originalEnv = { ...process.env };
27+
28+
beforeEach(() => {
29+
delete process.env.OLLAMA_HOST;
30+
delete process.env.OLLAMA_MODEL;
31+
});
32+
33+
afterEach(() => {
34+
process.env.OLLAMA_HOST = originalEnv.OLLAMA_HOST;
35+
process.env.OLLAMA_MODEL = originalEnv.OLLAMA_MODEL;
36+
removeConfig();
37+
});
38+
39+
describe('loadConfig', () => {
40+
it('returns hardcoded defaults when no file and no env vars', async () => {
41+
removeConfig();
42+
const { loadConfig } = await import('./config');
43+
const cfg = loadConfig();
44+
expect(cfg.host).toBe('http://localhost:11434');
45+
expect(cfg.model).toBe('gemma4');
46+
});
47+
48+
it('reads host and model from config file', async () => {
49+
writeConfig({ host: 'http://remote:11434', model: 'llama3' });
50+
const { loadConfig } = await import('./config');
51+
const cfg = loadConfig();
52+
expect(cfg.host).toBe('http://remote:11434');
53+
expect(cfg.model).toBe('llama3');
54+
});
55+
56+
it('env vars override config file values', async () => {
57+
writeConfig({ host: 'http://remote:11434', model: 'llama3' });
58+
process.env.OLLAMA_HOST = 'http://env-host:11434';
59+
process.env.OLLAMA_MODEL = 'codellama';
60+
const { loadConfig } = await import('./config');
61+
const cfg = loadConfig();
62+
expect(cfg.host).toBe('http://env-host:11434');
63+
expect(cfg.model).toBe('codellama');
64+
});
65+
66+
it('returns defaults for missing keys in config file', async () => {
67+
writeConfig({ model: 'llama3' });
68+
const { loadConfig } = await import('./config');
69+
const cfg = loadConfig();
70+
expect(cfg.host).toBe('http://localhost:11434');
71+
expect(cfg.model).toBe('llama3');
72+
});
73+
74+
it('returns defaults when config file is malformed JSON', async () => {
75+
mkdirSync(CONFIG_DIR, { recursive: true });
76+
writeFileSync(CONFIG_PATH, 'not json', 'utf8');
77+
const { loadConfig } = await import('./config');
78+
const cfg = loadConfig();
79+
expect(cfg.host).toBe('http://localhost:11434');
80+
expect(cfg.model).toBe('gemma4');
81+
});
82+
});
83+
84+
describe('saveConfig', () => {
85+
it('creates the config file with given values', async () => {
86+
removeConfig();
87+
const { saveConfig } = await import('./config');
88+
saveConfig({ model: 'mistral' });
89+
const saved = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as {
90+
model: string;
91+
};
92+
expect(saved.model).toBe('mistral');
93+
});
94+
95+
it('merges patch into existing config', async () => {
96+
writeConfig({ host: 'http://remote:11434', model: 'llama3' });
97+
const { saveConfig } = await import('./config');
98+
saveConfig({ model: 'mistral' });
99+
const saved = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as {
100+
host: string;
101+
model: string;
102+
};
103+
expect(saved.host).toBe('http://remote:11434');
104+
expect(saved.model).toBe('mistral');
105+
});
106+
});
107+
});

src/utils/config.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { homedir } from 'node:os';
3+
import { join } from 'node:path';
4+
5+
const CONFIG_DIR = join(homedir(), '.code-ollama');
6+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
7+
8+
const DEFAULTS = {
9+
host: 'http://localhost:11434',
10+
model: 'gemma4',
11+
} as const;
12+
13+
export interface Config {
14+
host: string;
15+
model: string;
16+
}
17+
18+
function readFile(): Partial<Config> {
19+
if (!existsSync(CONFIG_PATH)) return {};
20+
try {
21+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) as Partial<Config>;
22+
} catch {
23+
return {};
24+
}
25+
}
26+
27+
export function loadConfig(): Config {
28+
const file = readFile();
29+
return {
30+
host: process.env.OLLAMA_HOST ?? file.host ?? DEFAULTS.host,
31+
model: process.env.OLLAMA_MODEL ?? file.model ?? DEFAULTS.model,
32+
};
33+
}
34+
35+
export function saveConfig(patch: Partial<Config>): void {
36+
const current = readFile();
37+
const updated = { ...current, ...patch };
38+
mkdirSync(CONFIG_DIR, { recursive: true });
39+
writeFileSync(CONFIG_PATH, JSON.stringify(updated, null, 2) + '\n', 'utf8');
40+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * as config from './config';
12
export * as ollama from './ollama';
23
export * as screen from './screen';

src/utils/ollama.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Ollama } from 'ollama';
22

33
import type { Role } from '../constants';
4+
import { loadConfig } from './config';
45

5-
const OLLAMA_HOST = process.env.OLLAMA_HOST ?? 'http://localhost:11434';
6-
const DEFAULT_MODEL = process.env.OLLAMA_MODEL ?? 'gemma4';
6+
const { host, model: DEFAULT_MODEL } = loadConfig();
77

8-
const client = new Ollama({ host: OLLAMA_HOST });
8+
const client = new Ollama({ host });
99

1010
export interface Message {
1111
role: Role;
@@ -30,6 +30,6 @@ export async function* streamChat(
3030
}
3131

3232
export async function listModels(): Promise<string[]> {
33-
const response = await client.list();
34-
return response.models.map(({ name }) => name);
33+
const { models } = await client.list();
34+
return models.map(({ name }) => name);
3535
}

0 commit comments

Comments
 (0)