How the Configuration Manager in Desktop Commander Persists and Loads Settings

Desktop Commander stores its runtime configuration in a JSON file at ~/.claude-server-commander/config.json and manages it through a singleton ConfigManager class that provides atomic writes, non-blocking save scheduling, and automatic initialization.

The configuration system in wonderwhy-er/DesktopCommanderMCP handles everything from shell preferences to telemetry opt-outs. This article breaks down how the configuration manager initializes, persists changes safely, and recovers settings across process restarts.

Configuration File Location and Structure

The configuration manager determines where to store data through a single constant defined in src/config.ts:

// src/config.ts#L8-L10
export const CONFIG_FILE = join(homedir(), '.claude-server-commander', 'config.json');

All settings live in this JSON file, including the configuration version, default shell, telemetry preferences, and a persistent client ID for analytics.

Initialization: Loading or Creating the Config

The ConfigManager.init() method in src/config-manager.ts handles first-run scenarios and subsequent restarts. The initialization sequence runs automatically before any read or write operation.

What happens during initialization:

  1. Ensures directory exists — Creates ~/.claude-server-commander/ recursively if missing
  2. Checks for existing file — Uses fs.access to test for config.json
  3. Loads existing config — Parses the JSON into this.config if present
  4. Creates default config — Calls getDefaultConfig() if the file is absent
  5. Stamps version — Writes VERSION from src/version.ts into the config object
// Example: init runs automatically, but you can await it explicitly
import { configManager } from './config-manager.js';

await configManager.init(); // Optional — getConfig/setValue trigger it implicitly
const isFirstRun = configManager.isFirstRun(); // true if new file was created

The _isFirstRun flag lets the application detect fresh installations and show onboarding flows.

Reading Configuration Values

All read operations are asynchronous to guarantee initialization completes first.

Get the entire configuration:

const cfg = await configManager.getConfig();
console.log(cfg.defaultShell); // Current shell preference
console.log(cfg.telemetryEnabled); // boolean

Get a single value:

const limit = await configManager.getValue('fileReadLineLimit');

Both methods return shallow copies to prevent accidental mutation outside the manager.

Persisting Changes: Two Save Strategies

The configuration manager implements dual write paths optimized for different access patterns.

Blocking Writes with setValue

Use setValue when you need immediate persistence and confirmation:

await configManager.setValue('defaultShell', '/bin/zsh');
// Returns only after config.json is written to disk

Behind the scenes, setValue chains writes through this.writeChain — a promise queue that serializes concurrent operations. Even if one write fails, the chain continues (catch(() => {})) so subsequent saves aren't blocked. The actual disk operation happens in writeConfigToDisk() via fs.writeFile with pretty-printed JSON.

Non-Blocking Writes with setValueNonBlocking

High-frequency updates use setValueNonBlocking to avoid I/O bottlenecks:

// Called rapidly — only one disk write will occur
await configManager.setValueNonBlocking('filesProcessed', count);
await configManager.setValueNonBlocking('bytesRead', bytes);

The scheduleSave() method implements write coalescing: rapid calls set a saveScheduled flag, and a single pending write handles all accumulated changes. This prevents I/O storms from telemetry counters or batch operations.

Bulk Updates and Factory Reset

Merge multiple changes atomically:

await configManager.updateConfig({
  defaultShell: '/bin/fish',
  telemetryEnabled: true,
  fileReadLineLimit: 5000
});
// Single write operation for all three changes

Restore defaults:

await configManager.resetConfig();
// Replaces config with getDefaultConfig() and persists immediately

Persistent Client ID Generation

The manager lazily generates and stores a UUID for analytics tracking:

const clientId = await configManager.getOrCreateClientId();
// Same value returned across all future calls — stored in config.json

This ensures consistent user identification without external dependencies.

Telemetry Opt-Out Handling

When telemetryEnabled is set to false, the manager captures one final event before persisting the change. This respects user privacy while recording the opt-out decision itself — implemented with help from src/utils/capture.js.

await configManager.setValue('telemetryEnabled', false);
// Emits final telemetry event, then saves config with telemetry disabled

Key Implementation Files

File Responsibility
src/config-manager.ts Core singleton with init, save, and mutation logic
src/config.ts CONFIG_FILE constant and path resolution
src/version.ts VERSION string stamped into saved configs
src/utils/capture.js Telemetry emission for opt-out events

Summary

  • Storage location: ~/.claude-server-commander/config.json via CONFIG_FILE constant
  • Singleton access: Import configManager from src/config-manager.ts
  • Atomic writes: Promise chain serialization prevents corruption during concurrent updates
  • Performance optimization: Non-blocking saves with automatic coalescing for high-frequency changes
  • First-run detection: isFirstRun() returns true when default config is created fresh
  • Persistent identity: getOrCreateClientId() provides stable UUID across restarts

Frequently Asked Questions

What happens if the config file is corrupted?

The manager relies on standard JSON parsing. If JSON.parse throws during init(), the error propagates to the caller. No automatic recovery is implemented — corrupted files require manual deletion or restoration.

Can multiple processes safely write to the same config file?

The promise chain in saveConfig() serializes writes within a single process, but there is no cross-process file locking. Concurrent access from separate Node.js processes could theoretically corrupt the file.

How do I migrate settings when the configuration format changes?

The VERSION field is written into every saved config, but no automatic migration logic exists in the current implementation. Version-dependent branching would need to be added to init() or getDefaultConfig().

Why are read operations asynchronous when the file is already loaded?

The async wrapper guarantees that init() has completed, handling race conditions where getConfig() or getValue() is called immediately after module import before the background initialization finishes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →