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:
- Ensures directory exists — Creates
~/.claude-server-commander/recursively if missing - Checks for existing file — Uses
fs.accessto test forconfig.json - Loads existing config — Parses the JSON into
this.configif present - Creates default config — Calls
getDefaultConfig()if the file is absent - Stamps version — Writes
VERSIONfromsrc/version.tsinto 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.jsonviaCONFIG_FILEconstant - Singleton access: Import
configManagerfromsrc/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →