Skip to content
Open
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
7 changes: 6 additions & 1 deletion src/cli/tui/schema/llm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { CategoryDef } from './types.js';

const usesApiKey = (ctx: Parameters<NonNullable<CategoryDef['fields'][number]['visible']>>[0]): boolean =>
(ctx.pending.llmProvider ?? ctx.current.llmProvider) !== 'ollama';

export const llmCategory: CategoryDef = {
id: 'llm',
label: 'LLM Provider',
Expand All @@ -21,6 +24,7 @@ export const llmCategory: CategoryDef = {
},
],
default: 'anthropic',
required: true,
},
{
key: 'WIGOLO_LLM_API_KEY',
Expand All @@ -29,10 +33,11 @@ export const llmCategory: CategoryDef = {
kind: 'masked',
secret: true,
propagateToAgents: true,
required: usesApiKey,
help: 'Stored in OS keychain when available; never written to config.json.',
// Ollama is keyless — hide the API-key field when it's the chosen provider
// so the wizard never prompts for a credential the local server ignores.
visible: (ctx) => (ctx.pending.llmProvider ?? ctx.current.llmProvider) !== 'ollama',
visible: usesApiKey,
},
],
};
1 change: 1 addition & 0 deletions src/cli/tui/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface FieldDef {
max?: number;
secret?: true;
propagateToAgents?: boolean;
required?: boolean | ((ctx: Ctx) => boolean);
visible?: (ctx: Ctx) => boolean;
validate?: (v: unknown) => string | null;
futureNote?: string;
Expand Down
74 changes: 49 additions & 25 deletions src/cli/tui/state/required-fields.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
/**
* hasRequiredFields — pure predicate for entry routing.
*
* Returns true when the persisted config is complete enough to skip the
* first-run wizard:
* - a non-empty `llmProvider`, AND
* - for keyed cloud providers, a non-empty `llmApiKey`.
*
* The `ollama` provider is KEYLESS (it runs against a local LLM server), so it
* is complete with NO api key — requiring one would re-route a near-zero-
* friction ollama user back into the wizard. Cloud providers still require a
* key; absence of either field sends the user into setup.
*
* TODO: derive from schema once FieldDef carries a `required` flag —
* then this can auto-collect all required fields from CATALOG instead of
* hardcoding the paths above.
*/
import type { PersistedConfig } from '../../../persisted-config.js';
import { CATALOG } from '../schema/catalog.js';
import type { CategoryDef, Ctx, FieldDef } from '../schema/types.js';

export function hasRequiredFields(config: PersistedConfig): boolean {
const { settings } = config;
const provider = settings.llmProvider;
if (typeof provider !== 'string' || provider.length === 0) return false;
// Keyless local LLM server — no api key needed to count as configured.
if (provider === 'ollama') return true;
const key = settings.llmApiKey;
return typeof key === 'string' && key.length > 0;
function isActiveRequiredField(field: FieldDef, ctx: Ctx): boolean {
if (field.visible && !field.visible(ctx)) return false;
return typeof field.required === 'function' ? field.required(ctx) : field.required === true;
}

function hasCompleteValue(field: FieldDef, value: unknown): boolean {
if (value === undefined || value === null) return false;

if (field.kind === 'multiselect') {
return Array.isArray(value) && value.length > 0;
}
if (field.kind === 'number') {
return typeof value === 'number' && Number.isFinite(value);
}
if (field.kind === 'toggle') {
return typeof value === 'boolean';
}
if (typeof value === 'string') {
return value.trim().length > 0;
}
return false;
}

function hasSecretReference(config: PersistedConfig, field: FieldDef): boolean {
if (field.secret !== true) return false;
return hasCompleteValue(field, config.settings[`${field.settingsPath}KeyLocation`]);
}

/** Return whether persisted settings satisfy every active schema requirement. */
export function hasRequiredFields(
config: PersistedConfig,
catalog: ReadonlyArray<CategoryDef> = CATALOG,
): boolean {
const ctx: Ctx = { current: config.settings, pending: {} };
const requiredFields = catalog.flatMap((category) => category.fields)
.filter((field) => isActiveRequiredField(field, ctx));
return requiredFields.every((field) => {
if (hasCompleteValue(field, config.settings[field.settingsPath])) return true;
if (hasSecretReference(config, field)) return true;

// The legacy provider block predates per-field secret-location pointers.
// Scope it to the LLM key and require the selected provider to match.
return field.secret === true
&& field.settingsPath === 'llmApiKey'
&& config.provider?.name === config.settings.llmProvider
&& hasCompleteValue(field, config.provider?.keyLocation);
});
}
24 changes: 24 additions & 0 deletions tests/unit/cli/tui/entry-required-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ function writeCfg(file: string, settings: Record<string, unknown>): string {
return p;
}

function writePersistedCfg(file: string, config: Record<string, unknown>): string {
const p = join(tmpDir, file);
writeFileSync(p, JSON.stringify({ version: 1, ...config }), { mode: 0o600 });
return p;
}

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'wigolo-entry-rf-'));
});
Expand All @@ -40,6 +46,24 @@ describe('resolveEntry auto-routing with required-fields check', () => {
expect(r.firstRun).toBe(false);
});

it('config with a secret-location reference → home without persisting the key', async () => {
const p = writeCfg('key-reference.json', {
llmProvider: 'anthropic',
llmApiKeyKeyLocation: 'keychain',
});
const r = await resolveEntry({ mode: 'auto', configPath: p, isTTY: true });
expect(r.mode).toBe('home');
});

it('legacy provider block with a matching provider → home', async () => {
const p = writePersistedCfg('legacy-provider.json', {
settings: { llmProvider: 'openai' },
provider: { name: 'openai', keyLocation: 'file' },
});
const r = await resolveEntry({ mode: 'auto', configPath: p, isTTY: true });
expect(r.mode).toBe('home');
});

it('config exists but missing llmProvider → wizard', async () => {
const p = writeCfg('no-provider.json', { llmApiKey: 'sk-xxx' });
const r = await resolveEntry({ mode: 'auto', configPath: p, isTTY: true });
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/cli/tui/schema/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('llmCategory', () => {
]);
expect(provider?.options?.map((o) => o.value)).not.toContain('custom');
expect(provider?.default).toBe('anthropic');
expect(provider?.required).toBe(true);
});

it('exposes ollama as a keyless local-LLM choice (no api-key field shown when selected)', () => {
Expand Down Expand Up @@ -52,6 +53,9 @@ describe('llmCategory', () => {
expect(key?.secret).toBe(true);
expect(key?.propagateToAgents).toBe(true);
expect(key?.key).toBe('WIGOLO_LLM_API_KEY');
expect(typeof key?.required).toBe('function');
expect(key?.required?.({ current: { llmProvider: 'anthropic' }, pending: {} })).toBe(true);
expect(key?.required?.({ current: { llmProvider: 'ollama' }, pending: {} })).toBe(false);
// Help text must mention the keychain so users understand where secrets land.
expect(key?.help).toMatch(/keychain/i);
});
Expand Down
95 changes: 94 additions & 1 deletion tests/unit/cli/tui/state/required-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,27 @@
import { describe, it, expect } from 'vitest';
import { hasRequiredFields } from '../../../../../src/cli/tui/state/required-fields.js';
import type { PersistedConfig } from '../../../../../src/persisted-config.js';
import type { CategoryDef, FieldDef } from '../../../../../src/cli/tui/schema/types.js';

function cfg(settings: Record<string, unknown>): PersistedConfig {
return { version: 1, settings };
}

function catalogWith(...fields: FieldDef[]): ReadonlyArray<CategoryDef> {
return [{ id: 'advanced', label: 'Test', description: 'Test fields', fields }];
}

function requiredField(overrides: Partial<FieldDef> = {}): FieldDef {
return {
key: 'TEST_VALUE',
settingsPath: 'testValue',
label: 'Test value',
kind: 'text',
required: true,
...overrides,
};
}

describe('hasRequiredFields', () => {
it('empty config → false', () => {
expect(hasRequiredFields(cfg({}))).toBe(false);
Expand All @@ -32,7 +48,49 @@ describe('hasRequiredFields', () => {
});

it('provider + key both set → true', () => {
expect(hasRequiredFields(cfg({ llmProvider: 'anthropic', llmApiKey: 'sk-xxx' }))).toBe(true);
expect(hasRequiredFields(cfg({ llmProvider: 'anthropic', llmApiKey: 'test-key' }))).toBe(true);
});

it('recognizes a persisted secret-location reference without reading the key', () => {
expect(hasRequiredFields(cfg({
llmProvider: 'anthropic',
llmApiKeyKeyLocation: 'keychain',
}))).toBe(true);
expect(hasRequiredFields({
version: 1,
settings: { llmProvider: 'openai' },
provider: { name: 'openai', keyLocation: 'file' },
})).toBe(true);
});

it('rejects a legacy provider reference for a different selected provider', () => {
expect(hasRequiredFields({
version: 1,
settings: { llmProvider: 'openai' },
provider: { name: 'anthropic', keyLocation: 'keychain' },
})).toBe(false);
});

it('rejects a blank legacy provider key-location reference', () => {
expect(hasRequiredFields({
version: 1,
settings: { llmProvider: 'anthropic' },
provider: { name: 'anthropic', keyLocation: ' ' as 'keychain' },
})).toBe(false);
});

it('does not use the legacy LLM provider block for unrelated secret fields', () => {
const catalog = catalogWith(requiredField({
settingsPath: 'otherSecret',
kind: 'masked',
secret: true,
}));

expect(hasRequiredFields({
version: 1,
settings: {},
provider: { name: 'anthropic', keyLocation: 'keychain' },
}, catalog)).toBe(false);
});

it('provider is empty string → false', () => {
Expand Down Expand Up @@ -71,4 +129,39 @@ describe('hasRequiredFields', () => {
it('key is non-string (object) → false', () => {
expect(hasRequiredFields(cfg({ llmProvider: 'anthropic', llmApiKey: {} }))).toBe(false);
});

it('derives required paths from the supplied schema instead of provider names', () => {
const catalog = catalogWith(requiredField({ settingsPath: 'customRequired' }));

expect(hasRequiredFields(cfg({}), catalog)).toBe(false);
expect(hasRequiredFields(cfg({ customRequired: 'configured' }), catalog)).toBe(true);
});

it('evaluates conditional required rules against persisted settings', () => {
const catalog = catalogWith(requiredField({
required: (ctx) => ctx.current.mode === 'cloud',
}));

expect(hasRequiredFields(cfg({ mode: 'local' }), catalog)).toBe(true);
expect(hasRequiredFields(cfg({ mode: 'cloud' }), catalog)).toBe(false);
expect(hasRequiredFields(cfg({ mode: 'cloud', testValue: 'configured' }), catalog)).toBe(true);
});

it('ignores required fields that are not visible in the active schema context', () => {
const catalog = catalogWith(requiredField({ visible: () => false }));
expect(hasRequiredFields(cfg({}), catalog)).toBe(true);
});

it('treats empty strings and lists as missing while accepting zero and false', () => {
const textCatalog = catalogWith(requiredField());
const listCatalog = catalogWith(requiredField({ kind: 'multiselect' }));
const numberCatalog = catalogWith(requiredField({ kind: 'number' }));
const toggleCatalog = catalogWith(requiredField({ kind: 'toggle' }));

expect(hasRequiredFields(cfg({ testValue: ' ' }), textCatalog)).toBe(false);
expect(hasRequiredFields(cfg({ testValue: [] }), listCatalog)).toBe(false);
expect(hasRequiredFields(cfg({ testValue: ['one'] }), listCatalog)).toBe(true);
expect(hasRequiredFields(cfg({ testValue: 0 }), numberCatalog)).toBe(true);
expect(hasRequiredFields(cfg({ testValue: false }), toggleCatalog)).toBe(true);
});
});
Loading