Skip to content

Commit 0c5363d

Browse files
committed
feat: refactor config system to centralize model d
1 parent 1bc9c83 commit 0c5363d

10 files changed

Lines changed: 1157 additions & 1018 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ Verification order: `npm run lint` then `npm test`.
1919
## Architecture
2020

2121
- `src/index.js` — single entrypoint. `commander` CLI, defines subcommands + aliases (`c`=commit, `e`=easy, `st`=status, `p`=push, `t`=story, `s`=start, `pv`=provider, `m`=model). UI modules are **lazy-loaded** via `app()`/`require()` to keep startup fast — don't eagerly require UI modules at the top of index.js.
22-
- `src/helpers/` — pure logic: `git.js` (wraps `simple-git`), `ai.js` (provider HTTP via axios), `config.js` (config + saved AI connections), `patch.js`.
22+
- `src/helpers/` — pure logic: `git.js` (wraps `simple-git`), `ai.js` (provider HTTP via axios), `config.js` (config + saved AI connections + legacy migration), `providers.js` (single provider registry: labels, credential fields, model keys, defaults, model-fetch dispatch), `patch.js`.
2323
- `src/ui/` — all interaction: `app.js` (main menu loop), `common.js` (styles `s.*`, `clear`, `header`), `screen.js` (inquirer prompts, `spinner`/`done`/`fail`), `diff-view.js`.
2424
- `src/ui/modules/` — one file per feature, each exporting `doXxx(info)` for the menu flow plus smaller helpers. Follow this pattern for new features; keep git/AI logic in `helpers/`.
2525

2626
## Gotchas
2727

2828
- **Module-level caches**: `helpers/config.js` caches config (`getConfig` returns the same object), `helpers/git.js` caches the simple-git instance, `helpers/ai.js` caches provider connection status. Tests must call `resetConfigCache()` / `resetGitCache()` / `resetAIConnectionCache()` in `beforeEach` (existing tests do this).
2929
- **Config precedence**: defaults ← `~/.eckra/config.json` ← active saved AI connection (`aiConnections` map, resolved in `getConfig`) ← repo-local `.eckrarc` (searched up the directory tree) ← `ECKRA_*` env vars. `.eckrarc` is gitignored because it can hold API keys. Global config is written with `0600` permissions — keep that security behavior.
30-
- **Saved AI connections**: multiple provider credentials (per provider and per account) live in the global config under `aiConnections`; `activeAiConnection` selects one. Manage them only via the dedicated helpers (`saveAIConnection`, `setActiveAIConnection`, …) or `eckra provider` — the raw `aiConnections` key is excluded from `eckra config set/unset` (`MANAGED_CONFIG_KEYS`). When a connection is active, provider settings/model changes must be written back into it, not to the flat keys (see `saveProviderSettings` in `ui/modules/settings.js`).
30+
- **Connections-first AI settings**: all provider credentials/models live as named connections in the global config under `aiConnections`; `activeAiConnection` selects one (resolution: explicit → `default` → first alphabetical — see `resolveActiveConnectionName` in `helpers/config.js`). Setup writes a `default` connection; legacy flat keys (`openaiApiKey`, …) are auto-copied into it once by `migrateLegacyToDefaultIfNeeded` (flat keys stay on disk for downgrade safety). All provider metadata (labels, key fields, model keys, model fetching) comes from `src/helpers/providers.js` — add new providers there, not as switches. UI exposes only **Switch** and **Manage** (`manageProvidersMenu` + shared `connectionWizard`). `eckra config set/unset` rejects provider-managed keys (`PROVIDER_MANAGED_KEYS`) with guidance to use `eckra provider`.
3131
- **Provider URLs**: `lmStudioUrl` / `ollamaUrl` are normalized (trailing slashes stripped) in `getConfig` so downstream path concatenation doesn't double up `/`.
3232
- **Conventional commits**: AI-generated messages follow `type: subject` style; keep new commit messages consistent with the repo history (`feat:`, `fix:`, `refactor:`, `docs:`…).
3333
- `.eckrarc`, `.eckra/`, `docs/`, `*.log` are gitignored.

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Or jump straight into action:
7777
| `eckra doctor` | `dr` | Health check |
7878
| `eckra suggest` | `sg` | Print an AI commit message |
7979
| `eckra setup` | | Run the setup/onboarding wizard |
80-
| `eckra model` | `m` | Show current AI settings & change model/provider/connection |
80+
| `eckra model` | `m` | Show current AI settings & switch/manage providers |
8181

8282
> [!TIP]
8383
> `eckra e` stages everything, generates an AI message, and asks you before committing and pushing.
@@ -190,30 +190,32 @@ ollama pull qwen3.5:2b
190190
| `Amazon Bedrock` | Bedrock API key + AWS Region (`bedrock-runtime` endpoint) | `us.anthropic.claude-haiku-4-5` |
191191
| `Amazon Bedrock Mantle` | Bedrock API key + AWS Region (`bedrock-mantle` endpoint) | `us.anthropic.claude-haiku-4-5` |
192192

193-
Providers are configured via the settings menu (`More > Settings`) or `~/.eckra/config.json`. eckra fetches the available models for you — run `eckra model` to see your current AI settings (provider, connection, model, masked API key) and where they're stored, then change the model, re-configure the provider, or switch saved connections from the same menu. The same actions live in Settings.
193+
Providers are configured during setup and stored as **named connections** in `~/.eckra/config.json`. The configuration you pick during `eckra setup` becomes your `default` connection. eckra fetches the available models for you — run `eckra model` to see your current AI settings (provider, connection, model, masked API key) and where they're stored, then switch or manage providers from the same menu.
194194

195195
### Saved connections (multiple providers & accounts)
196196

197-
eckra can store several provider configurations at once — different providers and/or multiple accounts for the same provider (e.g. a work and a personal OpenAI key). One connection is active at a time and every AI call uses it.
197+
eckra stores several provider configurations side by side — different providers and/or multiple accounts for the same provider (e.g. a work and a personal OpenAI key). One connection is active at a time and every AI call uses it. With no explicit choice, eckra falls back to the `default` connection.
198198

199-
After configuring a provider in **More > Settings**, eckra offers to save it as a named connection. You can also add one directly, then switch anytime:
199+
The AI surfaces expose exactly two actions — **Switch Provider / Account** (pick an existing connection) and **Manage Providers** (everything else: add, edit credentials/model per connection, rename, delete). They live in the Settings menu and behind `eckra model`:
200200

201201
```bash
202202
eckra provider list # All saved connections, active marked with ✓
203-
eckra provider add # Interactive: provider → key → model → name
203+
eckra provider add # Interactive wizard: provider → key → model → name
204204
eckra provider add --name work --provider openai \
205205
--set openaiApiKey=sk-... openaiModel=gpt-5-mini --use # Non-interactive
206+
eckra provider edit work # Wizard: fix the API key or change the model
206207
eckra provider use work # Switch globally
207208
eckra provider use home --local # Pin a connection to this repo only (.eckrarc)
208209
eckra provider show work # Details (secrets masked)
209210
eckra provider rename work is # Rename (stays active if it was)
210211
eckra provider remove work -y # Delete
211212
```
212213

213-
The same actions live in the settings menu under **Switch Provider / Account** and **Manage Saved Providers**.
214+
> [!NOTE]
215+
> Upgrading from an older eckra? Your existing flat settings are migrated automatically into a `default` connection on first run — nothing to do. The old keys stay in the file for downgrade safety but are no longer used.
214216
215217
> [!NOTE]
216-
> The active connection can also be set per environment with `ECKRA_ACTIVE_AI_CONNECTION`, or per repository via `.eckrarc` (`eckra config set activeAiConnection <name> --local`). With no active connection, eckra falls back to your base settings.
218+
> The active connection can also be set per environment with `ECKRA_ACTIVE_AI_CONNECTION`, or per repository via `.eckrarc` (`eckra config set activeAiConnection <name> --local`).
217219
218220
> [!NOTE]
219221
> Per-repository overrides go in `.eckrarc` (gitignored, as it can hold API keys).
@@ -234,7 +236,7 @@ eckra config path # Config file path
234236
> [!NOTE]
235237
> Add `--local` to target the project's `.eckrarc` instead.
236238
237-
A few useful keys: `commitType` (commit message format), `subjectMaxLength` (max subject characters, default 50), `locale` (language for messages, default `en`), `timeout` (AI request timeout in ms, default 30000), and `activeAiConnection` (the saved connection in use). Saved connections themselves live under the `aiConnections` key — manage them with `eckra provider`, not `eckra config set`.
239+
A few useful keys: `commitType` (commit message format), `subjectMaxLength` (max subject characters, default 50), `locale` (language for messages, default `en`), `timeout` (AI request timeout in ms, default 30000), and `activeAiConnection` (the saved connection in use). Provider credentials and models live inside named connections — manage them with `eckra provider` / `eckra model`, not `eckra config set` (which now rejects those keys with guidance).
238240

239241
### Health check
240242

src/helpers/config.js

Lines changed: 123 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,36 +45,18 @@ const DEFAULT_CONFIG = {
4545
"Use concise, present tense, and descriptive language. Focus on the 'why' of the changes.",
4646
};
4747

48-
/**
49-
* Field names each provider may store inside a saved AI connection.
50-
* Single source of truth for validating `saveAIConnection` input.
51-
*/
52-
const PROVIDER_FIELDS = {
53-
lmstudio: ["lmStudioUrl", "model"],
54-
ollama: ["ollamaUrl", "ollamaModel"],
55-
openai: ["openaiApiKey", "openaiModel"],
56-
anthropic: ["anthropicApiKey", "anthropicModel"],
57-
openrouter: ["openrouterApiKey", "openrouterModel"],
58-
gemini: ["geminiApiKey", "geminiModel"],
59-
opencodego: ["opencodeGoApiKey", "opencodeGoModel"],
60-
deepseek: ["deepseekApiKey", "deepseekModel"],
61-
bedrock: ["bedrockApiKey", "bedrockRegion", "bedrockModel"],
62-
bedrockmantle: [
63-
"bedrockMantleApiKey",
64-
"bedrockMantleRegion",
65-
"bedrockMantleModel",
66-
],
67-
ollamacloud: ["ollamaCloudApiKey", "ollamaCloudModel"],
68-
};
69-
70-
const AI_PROVIDERS = Object.keys(PROVIDER_FIELDS);
48+
const { PROVIDER_FIELDS, AI_PROVIDERS } = require("./providers");
7149

7250
/**
7351
* Config keys managed through dedicated commands (`eckra provider`) rather
7452
* than `eckra config set` — raw maps are unsafe to edit as CLI strings.
7553
*/
7654
const MANAGED_CONFIG_KEYS = ["aiConnections"];
7755

56+
const PROVIDER_MANAGED_KEYS = [
57+
...new Set(Object.values(PROVIDER_FIELDS).flat()),
58+
];
59+
7860
/**
7961
* Ensure config directory exists
8062
*/
@@ -134,6 +116,74 @@ function findLocalConfig(startDir = process.cwd()) {
134116

135117
let _cachedConfig = null;
136118

119+
/**
120+
* Active connection resolution:
121+
* 1. The explicitly active name (precedence: local file > env var > global file)
122+
* 2. If that name is missing/empty and a `default` connection exists, use it
123+
* 3. Otherwise the first connection sorted alphabetically (if any)
124+
*/
125+
function resolveActiveConnectionName(
126+
rawActiveName,
127+
connections,
128+
localRaw,
129+
envOverrides,
130+
globalRaw
131+
) {
132+
if (typeof rawActiveName === "string" && rawActiveName.trim()) {
133+
return rawActiveName.trim();
134+
}
135+
const map =
136+
connections ||
137+
(globalRaw.aiConnections && typeof globalRaw.aiConnections === "object"
138+
? globalRaw.aiConnections
139+
: {});
140+
const names = Object.keys(map);
141+
if (names.includes("default")) return "default";
142+
if (names.length > 0)
143+
return names.slice().sort((a, b) => a.localeCompare(b))[0];
144+
return "";
145+
}
146+
147+
/**
148+
* Connections-first migration: legacy flat provider fields (e.g.
149+
* `openaiApiKey`) are copied into a `default` connection exactly once.
150+
* The flat keys are kept on disk for downgrade safety — only copied, not
151+
* removed. Returns true when a migration was written.
152+
*/
153+
function migrateLegacyToDefaultIfNeeded(raw) {
154+
const existing = raw.aiConnections;
155+
const hasConnections =
156+
existing &&
157+
typeof existing === "object" &&
158+
Object.keys(existing).length > 0;
159+
if (hasConnections) return false;
160+
161+
const provider = raw.aiProvider || DEFAULT_CONFIG.aiProvider;
162+
const fields = PROVIDER_FIELDS[provider];
163+
if (!fields) return false;
164+
165+
const hasLegacyValue = PROVIDER_MANAGED_KEYS.some(
166+
(k) => raw[k] !== undefined && raw[k] !== "" && raw[k] !== null
167+
);
168+
if (!hasLegacyValue) return false;
169+
170+
const entry = { provider };
171+
for (const field of fields) {
172+
const value = raw[field];
173+
if (value !== undefined && value !== null && value !== "") {
174+
entry[field] = value;
175+
}
176+
}
177+
if (Object.keys(entry).length <= 1) return false; // provider only, nothing to save
178+
179+
raw.aiConnections = { default: entry };
180+
if (!raw.activeAiConnection) raw.activeAiConnection = "default";
181+
try {
182+
writeConfigFile(JSON.stringify(raw, null, 2));
183+
} catch {}
184+
return true;
185+
}
186+
137187
const ENV_PREFIX = "ECKRA_";
138188

139189
/**
@@ -195,6 +245,14 @@ function getConfig() {
195245
}
196246
}
197247

248+
// 1b. Connections-first migration (legacy flat keys → `default` connection, once)
249+
if (Object.keys(globalRaw).length > 0) {
250+
const didMigrate = migrateLegacyToDefaultIfNeeded(globalRaw);
251+
if (didMigrate) {
252+
config = { ...config, ...globalRaw };
253+
}
254+
}
255+
198256
// 2. Local config raw (read once; applied after the active connection)
199257
let localRaw = {};
200258
const localConfigPath = findLocalConfig();
@@ -216,22 +274,30 @@ function getConfig() {
216274
const envOverrides = getEnvConfig();
217275

218276
// 4. Active saved AI connection. The name resolves as
219-
// .eckrarc > ECKRA_ACTIVE_AI_CONNECTION > global config; its fields are
220-
// merged over the global values but stay weaker than local/env overrides.
277+
// .eckrarc > ECKRA_ACTIVE_AI_CONNECTION > global config, falling back to
278+
// `default` and then the first alphabetical connection when unset.
221279
const hasLocalActive = Object.prototype.hasOwnProperty.call(
222280
localRaw,
223281
"activeAiConnection"
224282
);
225-
const activeName = hasLocalActive
283+
const rawActiveName = hasLocalActive
226284
? localRaw.activeAiConnection
227285
: envOverrides.activeAiConnection !== undefined
228286
? envOverrides.activeAiConnection
229287
: globalRaw.activeAiConnection;
288+
const _connectionsForActive =
289+
globalRaw.aiConnections && typeof globalRaw.aiConnections === "object"
290+
? globalRaw.aiConnections
291+
: {};
292+
const activeName = resolveActiveConnectionName(
293+
rawActiveName,
294+
_connectionsForActive,
295+
localRaw,
296+
envOverrides,
297+
globalRaw
298+
);
230299
if (typeof activeName === "string" && activeName) {
231-
const connections =
232-
globalRaw.aiConnections && typeof globalRaw.aiConnections === "object"
233-
? globalRaw.aiConnections
234-
: {};
300+
const connections = _connectionsForActive;
235301
const connection = connections[activeName];
236302
if (
237303
connection &&
@@ -319,13 +385,22 @@ function getRawConfig({ local = false } = {}) {
319385
}
320386

321387
/**
322-
* Whether `key` is a known config key (typo protection for set/unset)
388+
* Whether `key` is a provider-managed field (stored inside a connection).
389+
*/
390+
function isProviderManagedKey(key) {
391+
return PROVIDER_MANAGED_KEYS.includes(key);
392+
}
393+
394+
/**
395+
* Whether `key` is a known config key (typo protection for set/unset).
396+
* Provider-managed fields are not settable via `eckra config set`.
323397
*/
324398
function isValidConfigKey(key) {
325399
return (
326400
typeof key === "string" &&
327401
Object.prototype.hasOwnProperty.call(DEFAULT_CONFIG, key) &&
328-
!MANAGED_CONFIG_KEYS.includes(key)
402+
!MANAGED_CONFIG_KEYS.includes(key) &&
403+
!PROVIDER_MANAGED_KEYS.includes(key)
329404
);
330405
}
331406

@@ -345,9 +420,20 @@ function maskSecret(value) {
345420
* into the target file. Invalidates the in-memory config cache.
346421
*/
347422
function setConfigValue(key, value, { local = false } = {}) {
423+
if (isProviderManagedKey(key)) {
424+
throw new Error(
425+
`"${key}" is managed by your connections — use \`eckra provider edit <name>\` or run \`eckra setup\`. See \`eckra provider list\`.`
426+
);
427+
}
428+
if (MANAGED_CONFIG_KEYS.includes(key)) {
429+
throw new Error(
430+
`"${key}" is managed internally — use \`eckra provider\` to manage connections.`
431+
);
432+
}
348433
if (!isValidConfigKey(key)) {
349434
const validKeys = Object.keys(DEFAULT_CONFIG).filter(
350-
(k) => !MANAGED_CONFIG_KEYS.includes(k)
435+
(k) =>
436+
!MANAGED_CONFIG_KEYS.includes(k) && !PROVIDER_MANAGED_KEYS.includes(k)
351437
);
352438
throw new Error(
353439
`Unknown config key: "${key}". Valid keys: ${validKeys.join(", ")}`
@@ -574,6 +660,10 @@ module.exports = {
574660
PROVIDER_FIELDS,
575661
AI_PROVIDERS,
576662
MANAGED_CONFIG_KEYS,
663+
PROVIDER_MANAGED_KEYS,
664+
isProviderManagedKey,
665+
resolveActiveConnectionName,
666+
migrateLegacyToDefaultIfNeeded,
577667
normalizeUrl,
578668
envVarName,
579669
getEnvConfig,

0 commit comments

Comments
 (0)