cligentic
← back to catalog
cligentic block

notify-os

Fire a system notification across macOS (osascript), Linux (notify-send), Windows and WSL (PowerShell). Silently skips in CI. Typed verdict, never throws.

Install
notify-os demo
NPM dependencies

zero deps — pure TS

Block dependencies
Size

78 LOC

The problem

Long-running CLI operations complete while the user's focus is elsewhere. Without a system notification, the user has to poll the terminal or hope they notice the cursor changed.

The notify-os block fires a system notification on macOS, Linux, Windows, and WSL. In CI environments, it silently skips (not an error). Returns a typed verdict.

Quickstart

import { notifyOs } from "@/cli/platform/notify-os";
 
await longBuildProcess();
 
const result = await notifyOs("Build complete", "myapp v1.2.0 is ready.");
 
if (!result.sent) {
  console.log("Build complete: myapp v1.2.0 is ready.");
}

Backend detection

  • macOS: osascript -e 'display notification ...' with optional sound
  • Windows / WSL: PowerShell NotifyIcon.ShowBalloonTip
  • Linux: notify-send (libnotify, present on most desktop distros)
  • CI / headless: silently skipped, { sent: false, via: "skipped" }

Options

export type NotifyOptions = {
  dryRun?: boolean;
  /** App name shown in the notification. Defaults to "CLI". */
  appName?: string;
  /** Sound on macOS. Set false to suppress. */
  sound?: boolean;
};

Signature

export function notifyOs(
  title: string,
  message: string,
  options?: NotifyOptions,
): Promise<NotifyResult>;

Real-world pattern

Pair with long operations to close the feedback loop:

import { notifyOs } from "@/cli/platform/notify-os";
import { emit } from "@/cli/agent/json-mode";
 
program.command("deploy").action(async (opts) => {
  const result = await deployToProduction();
  emit(result, opts);
  await notifyOs("Deploy done", `${result.version} is live.`);
});

The notification fires for humans. Agents reading --json output don't need it because they already parsed the result from stdout.

Source

The full file, 78 lines.

This is the exact TypeScript that lands in your project when you run the install command. Read it, copy it, edit it, own it. cligentic never touches it again.

src/cli/platform/notify-os.ts
// cligentic block: notify-os
//
// Fires a system notification across macOS, Linux, Windows, and WSL.
// Returns a typed verdict instead of throwing.
//
// Usage:
//   import { notifyOs } from "./platform/notify-os";
//
//   const result = await notifyOs("Deploy complete", "myapp v1.2.0 is live");

import { execSync } from "node:child_process";
import { platform } from "node:os";
import { hasCommand, isCi, isWsl } from "./detect";

export type NotifyResult = {
  sent: boolean;
  via: "osascript" | "notify-send" | "powershell" | "skipped";
  reason?: string;
};

export type NotifyOptions = {
  dryRun?: boolean;
  appName?: string;
  sound?: boolean;
};

function escapeOsascript(s: string): string {
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

export async function notifyOs(
  title: string,
  message: string,
  options: NotifyOptions = {},
): Promise<NotifyResult> {
  const { dryRun = false, appName = "CLI", sound = true } = options;

  if (isCi()) return { sent: false, via: "skipped", reason: "CI environment" };

  const os = platform();

  if (os === "darwin") {
    const soundClause = sound ? ' sound name "default"' : "";
    const cmd = `osascript -e 'display notification "${escapeOsascript(message)}" with title "${escapeOsascript(title)}"${soundClause}'`;
    if (dryRun) return { sent: true, via: "osascript", reason: `would run: ${cmd}` };
    try {
      execSync(cmd, { stdio: "ignore", timeout: 5000 });
      return { sent: true, via: "osascript" };
    } catch (err) {
      return { sent: false, via: "osascript", reason: (err as Error).message };
    }
  }

  if (os === "win32" || isWsl()) {
    const psCmd = `powershell.exe -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(5000, '${title.replace(/'/g, "''")}', '${message.replace(/'/g, "''")}', 'Info')"`;
    if (dryRun) return { sent: true, via: "powershell", reason: "would run: PowerShell notification" };
    try {
      execSync(psCmd, { stdio: "ignore", timeout: 10000 });
      return { sent: true, via: "powershell" };
    } catch (err) {
      return { sent: false, via: "powershell", reason: (err as Error).message };
    }
  }

  if (hasCommand("notify-send")) {
    const cmd = `notify-send --app-name="${appName}" "${title.replace(/"/g, '\\"')}" "${message.replace(/"/g, '\\"')}"`;
    if (dryRun) return { sent: true, via: "notify-send", reason: `would run: ${cmd}` };
    try {
      execSync(cmd, { stdio: "ignore", timeout: 5000 });
      return { sent: true, via: "notify-send" };
    } catch (err) {
      return { sent: false, via: "notify-send", reason: (err as Error).message };
    }
  }

  return { sent: false, via: "skipped", reason: "no notification backend found" };
}