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
4 changes: 4 additions & 0 deletions .skills/nexus-mobile-compat/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ skill.
- 2026-10-10 | An iOS chat request reached HTTP 401, so the plugin loaded and this was a runtime authentication investigation. The mobile reachability gate passed; this does not validate device credentials or the native request bridge. | No procedure change.

- 2026-10-10 | User resolved the iOS 401 after replacing the key; the key input was compressed beside a full-width Get key button and may have appended to the existing value. | Scoped the existing API-key setting with a mobile full-width input and compact button below it; no transport workaround needed. On-device layout retest remains separate.

## 2026-10-13 — Setup detection

- No change: desktop config detection reused lazy desktopRequire calls behind capability guards. The production build passed the mobile import reachability check.
4 changes: 4 additions & 0 deletions .skills/nexus-storage/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,7 @@ to 0). Edit `.skills/` and run `npm run sync:skills`; never edit a mirror. |
## 2026-10-09 — Retry metadata replacement
- Observation: preserving omitted metadata for accounting patches also retained stale provider replay blocks when regenerating an answer.
- Change: verified omission/patch separately from explicit regeneration replacement, using the existing metadata persistence contract. No protocol change needed.

## 2026-10-13 — Accordion preference

- No change: the persist-new-data protocol correctly routed this UI preference to plugin settings. Existing Settings save/load preserved it; no event-store or schema change was needed.
6 changes: 6 additions & 0 deletions .skills/nexus-testing/refinement-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,9 @@ there while `npx jest` still works (node resolution walks up). Symlink it, or
- 2026-10-10 | Mobile authentication investigation exposed a validation/save mismatch outside the adapter; a successful validator fixture alone would not prove which credential chat later sends. | Regression coverage must cross the provider modal's final getConfig/autosave boundary and check stale validation completion. Existing mock-honesty guidance applies.

- 2026-10-12 | Windows Obsidian 1.14.4: explicit Obsidian.com CLI path, in-band vault assertion, plugin:reload, dev:errors, eval and dev:screenshot worked. dev:mobile on/off reloads the renderer; an immediate eval can temporarily report command not found. Mobile-emulated key-row measurement confirmed a full-width input and compact button below, without inspecting credentials. Jest's OS-temp transform cache hit EPERM; a workspace --cacheDirectory allowed the suite to run. | Recorded observed commands and limits; no testing protocol change. Release protocol now documents Windows CLI discovery.

## 2026-10-13 — Get started accordion verification

- No protocol change. Focused regressions failed under intentional policy/save mutations, then passed after restoration. Full-suite socket tests needed unsandboxed local socket access.
- `obsidian vaults` and `obsidian eval 'vault=Professor Synapse' code=…` worked outside the sandbox. `eval vault=Code` reached the focused vault instead; the in-band name assertion caught it before mutation.
- Verified the new Accordion against real Obsidian Component/DOM with detached elements and in-memory Settings persistence; no installed bundle or vault data was changed. This is a component check, not a plugin reload/deployment check.
3 changes: 2 additions & 1 deletion src/components/Accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class Accordion extends Component {
* @param title Accordion header title
* @param defaultOpen Whether accordion is open by default
*/
constructor(containerEl: HTMLElement, title: string, defaultOpen = false) {
constructor(containerEl: HTMLElement, title: string, defaultOpen = false, private onToggle?: (isOpen: boolean) => void) {
super();

this.containerEl = containerEl;
Expand Down Expand Up @@ -82,6 +82,7 @@ export class Accordion extends Component {
if (contentEl) {
contentEl.classList.toggle('is-open');
}
this.onToggle?.(this.isOpen);
}

/**
Expand Down
16 changes: 10 additions & 6 deletions src/settings/SettingsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SettingsRouter, RouterState, SettingsTab } from './SettingsRouter';
import { UpdateManager } from '../utils/UpdateManager';
import { supportsMCPBridge } from '../utils/platform';
import { Accordion } from '../components/Accordion';
import { getConfigStatus, hasConfiguredProviders } from './getStartedStatus';
import { shouldOpenGetStarted } from './getStartedStatus';

// Services
import { WorkspaceService } from '../services/WorkspaceService';
Expand Down Expand Up @@ -536,13 +536,17 @@ export class SettingsView extends PluginSettingTab {
return;
}

const hasProviders = hasConfiguredProviders(this.settingsManager.settings.llmProviders);
const mcpConfigured = getConfigStatus(this.app) === 'nexus-configured';

this.getStartedAccordion = new Accordion(
containerEl,
'Get Started',
!hasProviders || !mcpConfigured
'Get started',
shouldOpenGetStarted(this.app, this.settingsManager.settings),
(isOpen) => {
this.settingsManager.settings.getStartedOpen = isOpen;
void this.settingsManager.saveSettings().catch((error: unknown) => {
console.error('[SettingsView] Failed to save get started preference:', error);
new Notice('Could not save the get started preference.');
});
}
);
this.getStartedAccordion.rootEl.addClass('nexus-settings-accordion');

Expand Down
76 changes: 70 additions & 6 deletions src/settings/getStartedStatus.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { App, Platform } from 'obsidian';
import type { LLMProviderConfig, LLMProviderSettings } from '../types/llm/ProviderTypes';
import { getPrimaryServerKey } from '../constants/branding';
import { getServerKeyCandidates } from '../constants/branding';
import { supportsMCPBridge } from '../utils/platform';
import { desktopRequire } from '../utils/desktopRequire';
import type { MCPSettings } from '../types/plugin/PluginTypes';
import { hasCodexMcpServerConfig } from '../utils/codexMcpConfig';
import { LocalCliInstaller } from '../services/cli/LocalCliInstaller';

export type ConfigStatus =
| 'unsupported'
Expand All @@ -21,7 +24,20 @@ export function isProviderConfigured(providerId: string, config?: LLMProviderCon
return true;
}

return Boolean(config.apiKey);
if (config.driverKind === 'openai-compatible') {
return Boolean(config.openaiCompatible?.baseUrl?.trim());
}

if (providerId === 'anthropic-claude-code' || providerId === 'google-gemini-cli') {
return Boolean(config.oauth?.connected);
}

if (providerId === 'openai-codex' || providerId === 'github-copilot') {
return Boolean(config.oauth?.connected && config.apiKey?.trim());
}

// Ollama and LM Studio store their server URL in apiKey.
return Boolean(config.apiKey?.trim());
}

export function hasConfiguredProviders(settings?: LLMProviderSettings): boolean {
Expand Down Expand Up @@ -86,10 +102,11 @@ export function getConfigStatus(app: App): ConfigStatus {
}

const config = JSON.parse(content) as unknown;
const serverKey = getPrimaryServerKey(app.vault.getName());

if (isRecord(config) && isRecord(config.mcpServers) && config.mcpServers[serverKey]) {
return 'nexus-configured';
if (isRecord(config) && isRecord(config.mcpServers)) {
const servers = config.mcpServers;
if (getServerKeyCandidates(app.vault.getName()).some(key => Boolean(servers[key]))) {
return 'nexus-configured';
}
}

return 'config-exists';
Expand All @@ -103,6 +120,53 @@ export function isMCPConfigured(app: App): boolean {
return getConfigStatus(app) === 'nexus-configured';
}

/** Setup can use internal chat OR external agents; neither path requires the other. */
export function shouldOpenGetStarted(app: App, settings: MCPSettings): boolean {
if (typeof settings.getStartedOpen === 'boolean') {
return settings.getStartedOpen;
}
return !hasConfiguredProviders(settings.llmProviders) && !hasExternalAgentSetup(app);
}

export function hasExternalAgentSetup(app: App): boolean {
if (!Platform.isDesktop || !supportsMCPBridge()) {
return false;
}

// Installation is enough for onboarding. Avoid status(), which probes shells
// and runtime versions synchronously; those diagnostics belong in setup.
try {
const nodeFs = desktopRequire<typeof import('node:fs')>('node:fs');
if (nodeFs.existsSync(new LocalCliInstaller().getPaths().cliJsPath)) {
return true;
}
} catch {
// An unavailable CLI path must not hide another configured setup path.
}

try {
const nodeFs = desktopRequire<typeof import('node:fs')>('node:fs');
const path = desktopRequire<typeof import('node:path')>('node:path');
const home = Platform.isWin ? process.env.USERPROFILE || process.env.HOME || '' : process.env.HOME || '';
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
const configPath = path.join(codexHome, 'config.toml');
if (nodeFs.existsSync(configPath)) {
const content = nodeFs.readFileSync(configPath, 'utf-8');
if (getServerKeyCandidates(app.vault.getName()).some(key => hasCodexMcpServerConfig(content, key))) {
return true;
}
}
} catch {
// Missing or unreadable client configuration is not a settings-render error.
}

try {
return isMCPConfigured(app);
} catch {
return false;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
2 changes: 2 additions & 0 deletions src/types/plugin/PluginTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export interface ProcessedFilesData {
*/
export interface MCPSettings {
enabledVault: boolean;
/** Explicit accordion preference; absent means infer from configured setup paths. */
getStartedOpen?: boolean;
enableEmbeddings?: boolean; // Enable/disable local embeddings for semantic search (desktop only)
enableIngestion?: boolean; // Enable/disable PDF/audio ingestion UI and ingest-only model settings
autoIngestion?: boolean; // Automatically convert newly added supported binary files to Markdown
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/GetStartedStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/** Regression: a fully configured single setup path must not reopen onboarding. */
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { App, Platform } from 'obsidian';
import type { LLMProviderConfig } from '../../src/types/llm/ProviderTypes';
import { getServerKeyCandidates } from '../../src/constants/branding';
import { buildCodexMcpTomlSnippet } from '../../src/utils/codexMcpConfig';
import { LocalCliInstaller } from '../../src/services/cli/LocalCliInstaller';
import { getClaudeDesktopConfigPath, hasExternalAgentSetup, isProviderConfigured, shouldOpenGetStarted } from '../../src/settings/getStartedStatus';

let mockHome: string;
jest.mock('../../src/utils/desktopRequire', () => ({
desktopRequire: (name: string) => name === 'node:os'
? { homedir: () => mockHome }
: jest.requireActual(name)
}));
jest.mock('../../src/utils/platform', () => ({ supportsMCPBridge: () => Platform.isDesktop }));

const app = { vault: { getName: () => 'Setup test' } } as unknown as App;
const config = (extra: Partial<LLMProviderConfig> = {}): LLMProviderConfig => ({ enabled: true, apiKey: '', ...extra });
const connected = { connected: true, providerId: 'test', connectedAt: 1 };
const settings = { enabledVault: true };
const originalEnv = { HOME: process.env.HOME, CODEX_HOME: process.env.CODEX_HOME };

function write(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}

beforeEach(() => {
mockHome = mkdtempSync(join(tmpdir(), 'nexus-setup-test-'));
process.env.HOME = mockHome;
process.env.CODEX_HOME = join(mockHome, 'custom-codex');
Object.assign(Platform, { isDesktop: true, isMacOS: true, isWin: false });
});
afterEach(() => {
rmSync(mockHome, { recursive: true, force: true });
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});

it('opens for a fresh install, but either chat or external setup suffices', () => {
expect(shouldOpenGetStarted(app, settings)).toBe(true);
expect(shouldOpenGetStarted(app, {
...settings,
llmProviders: { defaultModel: { provider: 'openai', model: 'test' }, providers: { openai: config({ apiKey: 'test-key' }) } }
})).toBe(false);
write(new LocalCliInstaller().getPaths().cliJsPath, '// installed CLI');
expect(shouldOpenGetStarted(app, settings)).toBe(false);
});

it('honors explicit closed and open preferences regardless of setup', () => {
expect(shouldOpenGetStarted(app, { ...settings, getStartedOpen: false })).toBe(false);
write(new LocalCliInstaller().getPaths().cliJsPath, '// installed CLI');
expect(shouldOpenGetStarted(app, { ...settings, getStartedOpen: true })).toBe(true);
});

it.each(getServerKeyCandidates('Setup test'))('recognizes Claude MCP configuration for %s', (key) => {
write(getClaudeDesktopConfigPath()!, JSON.stringify({ mcpServers: { [key]: { command: 'node', args: ['connector.js'] } } }));
expect(shouldOpenGetStarted(app, settings)).toBe(false);
});

it.each(getServerKeyCandidates('Setup test'))('recognizes Codex-only configuration for %s in CODEX_HOME', (key) => {
write(join(process.env.CODEX_HOME!, 'config.toml'), buildCodexMcpTomlSnippet(key, 'node', ['connector.js']));
expect(shouldOpenGetStarted(app, settings)).toBe(false);
});

it('uses the default Codex home when no override exists', () => {
delete process.env.CODEX_HOME;
write(join(mockHome, '.codex/config.toml'), buildCodexMcpTomlSnippet(getServerKeyCandidates('Setup test')[0], 'node', []));
expect(hasExternalAgentSetup(app)).toBe(true);
});

it('does not count a different vault, and tolerates unreadable Codex configuration', () => {
write(join(process.env.CODEX_HOME!, 'config.toml'), buildCodexMcpTomlSnippet('nexus-other', 'node', []));
expect(hasExternalAgentSetup(app)).toBe(false);
rmSync(join(process.env.CODEX_HOME!, 'config.toml'));
mkdirSync(join(process.env.CODEX_HOME!, 'config.toml'));
expect(hasExternalAgentSetup(app)).toBe(false);
});

it('does not inspect desktop setup on mobile', () => {
Platform.isDesktop = false;
expect(hasExternalAgentSetup(app)).toBe(false);
});

it.each(['anthropic-claude-code', 'google-gemini-cli'])('recognizes connected %s without an API key', (id) => {
expect(isProviderConfigured(id, config({ oauth: connected }))).toBe(true);
expect(isProviderConfigured(id, config())).toBe(false);
});

it.each(['openai-codex', 'github-copilot'])('requires OAuth connection and token for %s', (id) => {
expect(isProviderConfigured(id, config({ apiKey: 'token', oauth: connected }))).toBe(true);
expect(isProviderConfigured(id, config({ oauth: connected }))).toBe(false);
expect(isProviderConfigured(id, config({ apiKey: 'token' }))).toBe(false);
});

it('recognizes a keyless endpoint and rejects disabled or blank configurations', () => {
const endpoint = config({ driverKind: 'openai-compatible', openaiCompatible: { schemaVersion: 1, displayName: 'Local', baseUrl: 'http://localhost:1234/v1', models: {} } });
expect(isProviderConfigured('local-endpoint', endpoint)).toBe(true);
expect(isProviderConfigured('local-endpoint', { ...endpoint, enabled: false })).toBe(false);
expect(isProviderConfigured('local-endpoint', { ...endpoint, openaiCompatible: undefined })).toBe(false);
expect(isProviderConfigured('openai', config({ apiKey: ' ' }))).toBe(false);
expect(isProviderConfigured('webllm', config())).toBe(true);
for (const id of ['ollama', 'lmstudio']) {
expect(isProviderConfigured(id, config({ apiKey: 'http://localhost:1234' }))).toBe(true);
expect(isProviderConfigured(id, config())).toBe(false);
}
});
53 changes: 49 additions & 4 deletions tests/unit/SettingsViewDataTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ jest.mock('../../src/utils/platform', () => ({
supportsMCPBridge: jest.fn().mockReturnValue(false)
}));

jest.mock('../../src/settings/getStartedStatus', () => ({
getConfigStatus: jest.fn().mockReturnValue('nexus-configured'),
hasConfiguredProviders: jest.fn().mockReturnValue(true)
}));
jest.mock('../../src/settings/getStartedStatus', () => jest.requireActual('../../src/settings/getStartedStatus'));

jest.mock('../../src/components/UnifiedTabs', () => ({
UnifiedTabs: jest.fn().mockImplementation((options) => {
Expand Down Expand Up @@ -156,3 +153,51 @@ describe('SettingsView data tab wiring', () => {
expect(mockDataTab).toHaveBeenCalled();
});
});


it('saves accordion toggles and restores them in a new settings/view instance', async () => {
const { Settings } = await import('../../src/settings');
const bridge = jest.requireMock('../../src/utils/platform').supportsMCPBridge as jest.Mock;
bridge.mockReturnValue(true);
const accordion = jest.requireMock('../../src/components/Accordion').Accordion as jest.Mock;
let saved: Record<string, unknown> = { getStartedOpen: true };
const app = { vault: { configDir: '.obsidian' }, workspace: {} };
const plugin = {
app,
manifest: { id: 'nexus', version: 'test' },
loadData: jest.fn(async () => saved),
saveData: jest.fn(async (data: Record<string, unknown>) => { saved = JSON.parse(JSON.stringify(data)); })
};
const render = (manager: InstanceType<typeof Settings>) => {
const view = new SettingsView(app as never, plugin as never, manager);
view.containerEl = createMockElement('div') as unknown as HTMLElement;
// Content is unrelated; exercise the actual accordion policy and save callback.
jest.spyOn(view as unknown as { renderGetStartedContent(): Promise<void> }, 'renderGetStartedContent').mockResolvedValue();
view.display();
return view;
};
try {
const first = new Settings(plugin as never);
await first.loadSettings();
const view = render(first);
expect(accordion.mock.calls[accordion.mock.calls.length - 1][2]).toBe(true);
accordion.mock.calls[accordion.mock.calls.length - 1][3](false);
// Drain the real settings save lock and promise chain.
await new Promise(resolve => setImmediate(resolve));
expect(plugin.saveData).toHaveBeenCalledWith(expect.objectContaining({ getStartedOpen: false }));
view.cleanup();

const reloaded = new Settings(plugin as never);
await reloaded.loadSettings();
const secondView = render(reloaded);
expect(accordion.mock.calls[accordion.mock.calls.length - 1][2]).toBe(false);
accordion.mock.calls[accordion.mock.calls.length - 1][3](true);
await new Promise(resolve => setImmediate(resolve));
const reopened = new Settings(plugin as never);
await reopened.loadSettings();
expect(reopened.settings.getStartedOpen).toBe(true);
secondView.cleanup();
} finally {
bridge.mockReturnValue(false);
}
});
Loading