Read OSS

Configuration, Profiles, and the CLI: How Hyper is Customized

Intermediate

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

app/config/paths.ts#L17-L21

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.json is auto-generated from the TypeScript types via typescript-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:

app/config/import.ts

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:

app/config/init.ts#L33-L61

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:

app/config.ts#L40-L70

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:

  1. Copy local plugins from .hyper_plugins/local to the new location
  2. Parse the JS config using vm.Script and extract the exported object
  3. Deep merge with defaults to create a complete JSON config
  4. Extract non-serializable config (computed values, functions) into a migrated-hyper3-config.js local plugin using AST transformation via recast
  5. 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:

cli/index.ts#L55-L186

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:

cli/index.ts#L192-L258

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 (like docs or version) 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.