Configuration, Profiles, and the CLI: How Hyper is Customized
Prerequisites
- ›Article 1: Architecture and Project Navigation
- ›Article 5: Plugin System Deep Dive
- ›Basic understanding of XDG Base Directory Specification
Configuration, Profiles, and the CLI: How Hyper is Customized
Throughout this series, we've seen configuration data flow through nearly every subsystem: the main process reads it, the plugin system decorates it, the renderer applies it to xterm.js, and the RPC bridge carries updates between processes. But we've treated it as a black box. In this final article, we'll open that box — examining how hyper.json is structured, how the multi-stage merging pipeline assembles the final config, how the profile system enables context-specific overrides, and how the standalone command-line tool manages plugins by directly editing the config file.
Config File Format and Filesystem Layout
Hyper's configuration lives in a single JSON file with four top-level keys:
app/config/config-default.json
{
"$schema": "./schema.json",
"config": { /* terminal settings */ },
"plugins": [],
"localPlugins": [],
"keymaps": {}
}
The config key holds all terminal settings — fonts, colors, shell path, scrollback, cursor style, and more. The plugins array lists npm package names to install. localPlugins references directories in the local plugins folder. keymaps overrides keyboard shortcuts.
The filesystem layout is determined by app/config/paths.ts:
| Path | Platform | Location |
|---|---|---|
| Config dir | macOS/Linux | ~/.config/Hyper/ (or $XDG_CONFIG_HOME/Hyper/) |
| Config dir | Windows | %APPDATA%/Hyper/ |
| Config file | All | <config dir>/hyper.json |
| Plugins dir | All | <config dir>/plugins/ |
| Local plugins | All | <config dir>/plugins/local/ |
| Plugin cache | All | <config dir>/plugins/cache/ |
| Dev override | Dev mode | <repo root>/hyper.json |
The XDG support is noteworthy: if $XDG_CONFIG_HOME is set, Hyper respects it. In dev mode, the config path is overridden to the repository root if a hyper.json exists there — this lets developers test config changes without modifying their global settings.
Tip: The schema file at
app/config/schema.jsonis auto-generated from the TypeScript types viatypescript-json-schema ./typings/config.d.ts rawConfig. The"$schema"reference in the default config enables IDE autocompletion in VS Code and other JSON-schema-aware editors.
Config Loading and Merging Pipeline
The config loading process starts with _import() and flows through multiple stages:
flowchart TD
A["Read config-default.json"] --> B["Read platform keymaps\n(darwin.json / win32.json / linux.json)"]
B --> C["Read user hyper.json"]
C --> D["Attempt legacy migration\n(.hyper.js → hyper.json)"]
D --> E["_init(): Deep merge\ndefaults + user config"]
E --> F["Normalize profiles\n(ensure default profile exists)"]
F --> G["Merge platform keymaps\nwith user keymaps"]
G --> H["Filter null/undefined\nfrom plugin arrays"]
H --> I["parsedConfig object"]
The _init function handles the merging:
The deep merge uses Lodash's merge(), which recursively merges objects while arrays are replaced entirely. The profile normalization ensures that every config always has at least one profile named "default" with an empty config object.
Keymaps are merged with a simpler strategy — user keymaps override platform defaults via spread: {...defaultCfg.keymaps, ...userCfg?.keymaps}. Then mapKeys normalizes key combinations (ensuring they're arrays, handling deprecated formats).
Error handling is graceful throughout: if hyper.json fails to parse, the default config is used instead with a notification. If the defaults file itself is missing, an empty object is used as a fallback.
The Profile System
Profiles enable context-specific configuration overrides — different shells, colors, or fonts for different use cases. The profile resolution happens in app/config.ts#L97-L108:
export const getProfileConfig = (profileName: string): configOptions => {
const {profiles, defaultProfile, ...baseConfig} = cfg.config;
const profileConfig = profiles.find((p) => p.name === profileName)?.config || {};
for (const key in profileConfig) {
if (typeof baseConfig[key] === 'object' && !Array.isArray(baseConfig[key])) {
baseConfig[key] = {...baseConfig[key], ...profileConfig[key]};
} else {
baseConfig[key] = profileConfig[key];
}
}
return {...baseConfig, defaultProfile, profiles};
};
flowchart LR
A["Base Config\n(all keys except profiles)"] --> D["Merge Strategy"]
B["Profile Config\n(profile-specific overrides)"] --> D
D --> E{"Value type?"}
E -->|"Object (not Array)"| F["Shallow merge\n{...base, ...profile}"]
E -->|"Scalar or Array"| G["Replace entirely"]
F --> H["Final Config"]
G --> H
The merge strategy is notably shallow for objects — a profile's colors override merges with the base colors, so you only need to specify the colors you want to change. But arrays (like shellArgs) are replaced entirely, not concatenated.
Profile-specific commands are auto-generated in app/commands.ts#L150-L163:
getConfig().profiles.forEach((profile) => {
commands[`window:new:${profile.name}`] = () => {
setTimeout(() => app.createWindow(undefined, undefined, profile.name), 0);
};
commands[`tab:new:${profile.name}`] = (focusedWindow) => {
focusedWindow?.rpc.emit('termgroup add req', {profile: profile.name});
};
// ... pane:splitRight, pane:splitDown
});
Each profile automatically gets commands for opening a new window, tab, or split pane with that profile's configuration. These can be bound to keyboard shortcuts via the keymaps config.
Config Watching and Change Propagation
Config changes are detected through chokidar file watching:
The watcher uses a 100ms debounce (via setTimeout) to ensure the file write is complete before reading. When a change is detected, the propagation cascades through the entire system:
sequenceDiagram
participant FS as hyper.json
participant CW as Chokidar Watcher
participant CF as Config Module
participant P as Plugin System
participant W as BrowserWindow
participant R as Renderer
FS->>CW: File changed
CW->>CF: onChange callback (100ms debounce)
CF->>CF: Re-import and parse config
CF->>CF: Notify all subscribers
Note over CF: Subscriber 1: Menu rebuild
CF->>W: Menu.setApplicationMenu(newMenu)
Note over CF: Subscriber 2: Plugin check
CF->>P: Compare JSON.stringify(plugins)
P->>P: If changed → updatePlugins()
Note over CF: Subscriber 3: Window update
CF->>W: webContents.send('config change')
W->>R: Renderer receives config change
R->>R: config.subscribe callback
R->>R: store.dispatch(reloadConfig(newConfig))
R->>R: React re-renders with new config
The subscriber pattern is simple — an array of callbacks with a subscribe function that returns an unsubscribe function:
export const subscribe = (fn: Function) => {
watchers.push(fn);
return () => {
watchers.splice(watchers.indexOf(fn), 1);
};
};
Each BrowserWindow subscribes to config changes in app/ui/window.ts#L78-L93. It sends a 'config change' event to the renderer and also checks whether the shell configuration has changed — if so, it notifies the user that a new tab is needed to use the new shell.
Legacy Migration and Schema Generation
Hyper 3 used a JavaScript config file (.hyper.js) with module.exports. Hyper 4 moved to JSON (hyper.json). The migration at app/config/migrate.ts handles this transition:
app/config/migrate.ts#L147-L190
The migration only runs if hyper.json doesn't exist yet but .hyper.js does. The process:
- Copy local plugins from
.hyper_plugins/localto the new location - Parse the JS config using
vm.Scriptand extract the exported object - Deep merge with defaults to create a complete JSON config
- Extract non-serializable config (computed values, functions) into a
migrated-hyper3-config.jslocal plugin using AST transformation viarecast - Write the new
hyper.json
The configToPlugin function is particularly clever — it uses AST manipulation to extract any non-JSON-serializable expressions from the old JS config and wraps them in a decorateConfig plugin, so they continue to work transparently.
flowchart TD
A[".hyper.js exists\nhyper.json missing"] --> B["Parse JS with vm.Script"]
B --> C["Extract module.exports"]
C --> D["Deep merge with defaults"]
D --> E["Write hyper.json"]
C --> F["AST-analyze for non-JSON values"]
F --> G{"Has computed/function values?"}
G -->|Yes| H["Generate migrated-hyper3-config.js\nas local plugin"]
G -->|No| I["Done"]
H --> J["Add to localPlugins array"]
J --> I
The CLI Tool: Plugin Management
The cli/index.ts is a standalone Node.js tool that ships alongside the Electron app. It manages plugins by directly reading and writing hyper.json — there's no IPC communication with the running app.
The CLI provides six subcommands:
| Command | Aliases | Action |
|---|---|---|
hyper install <plugin> |
i |
Validate on npm, add to plugins array |
hyper uninstall <plugin> |
u, rm, remove |
Remove from plugins array |
hyper list |
ls |
Print installed plugins |
hyper search <query> |
s |
Search npms.io for hyper-plugin/hyper-theme packages |
hyper list-remote |
lsr, ls-remote |
List all available plugins |
hyper docs <plugin> |
d, h, home |
Open plugin's npm page |
The install flow at cli/api.ts#L100-L119 validates that the package exists on npm before adding it to the config:
function install(plugin, locally?) {
return existsOnNpm(plugin)
.catch((err) => {
if (statusCode === 404 || statusCode === 200) {
return Promise.reject(`${plugin} not found on npm`);
}
return Promise.reject(`Plugin check failed...`);
})
.then(() => {
if (isInstalled(plugin, locally)) {
return Promise.reject(`${plugin} is already installed`);
}
const config = getParsedFile();
config[locally ? 'localPlugins' : 'plugins'] = [...array, plugin];
save(config);
});
}
When no subcommand is given, the CLI launches Hyper itself. On macOS, it uses open -b co.zeit.hyper to prevent multiple instances. On other platforms, it spawns Electron as a detached child process:
The HYPER_CLI=1 environment variable is set when launching, signaling to the Electron app that it was started from the CLI. ELECTRON_NO_ATTACH_CONSOLE=1 prevents the Electron process from inheriting the CLI's console, keeping the terminal clean.
Tip: The CLI reads the config file lazily using memoization (see
cli/api.ts). This means subcommands that don't need the config (likedocsorversion) won't fail if the config file is missing or malformed — they simply never read it.
Series Conclusion
Over six articles, we've traced the complete architecture of Hyper — from the three-process design and webpack build tool, through the typed RPC bridge and terminal session lifecycle, into the Redux middleware chain that bypasses React for performance, the xterm.js component wrapper with its WebGL fallback, the 38-extension-point plugin system, and finally the configuration pipeline that ties it all together.
Hyper represents an interesting architectural choice: using web technologies for a performance-critical application, then building increasingly clever optimizations (V8 snapshots, write middleware, DataBatcher, DOM preservation) to claw back the performance lost to that abstraction layer. Whether you're building Electron apps, designing plugin systems, or just curious about how a terminal works under the hood, the patterns in this codebase — service locator objects, UUID-scoped channels, error-boundary decoration chains, and immutable tree state — are worth studying and adapting for your own projects.