How It Works (Internals)

Internal Architecture

Execution flow

bin/clash-verge.js (shebang)
  └─► index.js (main)
        ├─► require('./constants')  — reads paths
        ├─► process.argv            — checks for --help
        └─► child_process.spawn('sudo', [path])
              └─► exec() with env { ...process.env, CLASH_VERGE_TUN: '1' }

Key code path — index.js (main logic)

const { spawn } = require('child_process');
const { CLASH_VERGE_PATH } = require('./constants');

const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
  console.log('Usage: clash-verge-cli [--help]');
  process.exit(0);
}

const child = spawn('sudo', [CLASH_VERGE_PATH + '/Contents/MacOS/Clash Verge'], {
  stdio: 'inherit',
  env: { ...process.env, CLASH_VERGE_TUN: '1' }
});

child.on('error', (err) => {
  console.error('Failed to start Clash Verge:', err.message);
  process.exit(1);
});

child.on('exit', (code) => {
  console.log('Clash Verge exited with code', code);
  process.exit(code);
});

Why sudo?

macOS requires root to create a utun (userspace TUN) interface. Without sudo:

Error: permission denied — cannot create TUN interface

The CLI wraps the binary in sudo so macOS asks for your password in the terminal instead of popping up a GUI dialog.

Why stdio: 'inherit'?

This forwards Clash Verge's stdout/stderr to your terminal. You'll see the app's startup logs. If you set it to 'pipe', those logs are suppressed.

Error handling

  • sudo not found: spawn emits 'error' → exits 1 with message
  • Wrong Clash Verge path: spawn emits 'error' (ENOENT) → exits 1
  • Wrong password: sudo itself handles this — it prints "Sorry, try again" and exits
  • Clash Verge crashes: exit code forwarded to your shell