diff --git a/CHANGELOG.md b/CHANGELOG.md index 362898d..178ddc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to cymbal are documented here. +## [Unreleased] + +### Added + +- **OpenCode plugins now surface update notices through native OS notifications** — when a newer cymbal version is available, the OpenCode plugin shows a platform-native notification (macOS Notification Center via `osascript`, Linux via `notify-send`, Windows via PowerShell) so users see updates regardless of TUI or Desktop mode. Respects `CYMBAL_NO_UPDATE_NOTIFIER` and cymbal's per-version notification throttle. ([#23](https://github.com/1broseidon/cymbal/issues/23)) +- **New `cymbal hook notify` command** — emits a structured JSON payload with update availability, version, and install command for agent plugins that want to surface update notices outside hidden system context. Supports `--format=json|text` and `--update=cache|if-stale`. + ## [0.13.1] - 2026-05-06 ### Changed diff --git a/README.md b/README.md index 3e93c09..8db61ad 100644 --- a/README.md +++ b/README.md @@ -319,9 +319,11 @@ cymbal hook install opencode This installs a cymbal-managed OpenCode plugin under the documented plugin directory for the chosen scope. The plugin refreshes session guidance via `cymbal hook remind --update=if-stale` and soft-nudges bash grep/find/fd usage -back toward cymbal-first navigation on non-Windows shells. Reminder/update guidance stays fresh -without editing `AGENTS.md`. Cymbal still never self-updates by default; it -only tells the agent or user which explicit update command to run. +back toward cymbal-first navigation on non-Windows shells. When an update is +available, the plugin also shows a native OS notification so users see it in +both TUI and Desktop mode. Reminder/update guidance stays fresh without editing +`AGENTS.md`. Cymbal still never self-updates by default; it only tells the +agent or user which explicit update command to run. Claude Code also has a one-line installer: diff --git a/cmd/hook.go b/cmd/hook.go index bd801a0..d389595 100644 --- a/cmd/hook.go +++ b/cmd/hook.go @@ -30,6 +30,9 @@ import ( // cymbal hook remind print a short system block an agent can inject at // session start or on reminders. // +// cymbal hook notify emit a structured update notification payload for +// agent plugins that want to surface update notices. +// // The nudge/remind surface is agent-agnostic. For the most popular agent we // also ship a one-liner installer: // @@ -43,11 +46,11 @@ import ( var hookCmd = &cobra.Command{ Use: "hook", - Short: "Agent-integration hooks (nudge, remind, install)", + Short: "Agent-integration hooks (nudge, remind, notify, install)", Long: `Hooks that keep coding agents using cymbal instead of sliding back to raw grep/find as context grows. See https://github.com/1broseidon/cymbal/issues/23. -The three primitive subcommands are agent-agnostic. Use 'hook install ' +The agent-agnostic subcommands are nudge, remind, and notify. Use 'hook install ' to wire them into your agent's native hook points.`, } @@ -107,6 +110,27 @@ Update checks: }, } +var hookNotifyCmd = &cobra.Command{ + Use: "notify [--format=json|text] [--update=cache|if-stale]", + Short: "Emit a structured update notification payload for agent plugins", + Long: `Emit a structured update notification payload when cymbal has an +available update and the notification throttle allows it. + +Formats: + --format=json (default) structured payload for agent plugins + --format=text plain text notice; empty output when no notice is due + +Update checks: + --update=cache (default) use cached update status only + --update=if-stale refresh update status with a bounded live check only + when the cache is stale or missing`, + RunE: func(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + updateMode, _ := cmd.Flags().GetString("update") + return emitHookNotify(cmd.OutOrStdout(), format, updateMode) + }, +} + var hookInstallCmd = &cobra.Command{ Use: "install ", Short: "Install cymbal hooks into the given agent (claude-code, opencode)", @@ -141,6 +165,8 @@ func init() { hookNudgeCmd.Flags().String("format", "claude-code", "output format: claude-code, text, json") hookRemindCmd.Flags().String("format", "text", "output format: text, json, claude-code") hookRemindCmd.Flags().String("update", "cache", "update check mode: cache, if-stale") + hookNotifyCmd.Flags().String("format", "json", "output format: json, text") + hookNotifyCmd.Flags().String("update", "cache", "update check mode: cache, if-stale") hookInstallCmd.Flags().String("scope", "user", "install scope: user (default) or project") hookInstallCmd.Flags().Bool("dry-run", false, "show intended changes without writing") hookUninstallCmd.Flags().String("scope", "user", "uninstall scope: user (default) or project") @@ -148,6 +174,7 @@ func init() { hookCmd.AddCommand(hookNudgeCmd) hookCmd.AddCommand(hookRemindCmd) + hookCmd.AddCommand(hookNotifyCmd) hookCmd.AddCommand(hookInstallCmd) hookCmd.AddCommand(hookUninstallCmd) rootCmd.AddCommand(hookCmd) @@ -492,7 +519,12 @@ const ( remindUpdateTimeout = 800 * time.Millisecond ) -var reminderUpdateStatus = updatecheck.GetStatus +var ( + reminderUpdateStatus = updatecheck.GetStatus + hookNotifyStatus = updatecheck.GetStatus + hookNotifyShouldNotify = updatecheck.ShouldNotify + hookNotifyMarkNotified = updatecheck.MarkNotified +) func emitRemind(w io.Writer, format string) error { return emitRemindWithUpdate(w, format, remindUpdateCache) @@ -537,6 +569,67 @@ func emitRemindWithUpdate(w io.Writer, format, updateMode string) error { } } +type hookNotifyPayload struct { + Notify bool `json:"notify"` + LatestVersion string `json:"latestVersion,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Command string `json:"command,omitempty"` + ReleaseURL string `json:"releaseURL,omitempty"` +} + +func emitHookNotify(w io.Writer, format, updateMode string) error { + allowNetwork, timeout, err := reminderUpdateOptions(updateMode) + if err != nil { + return err + } + status, _ := hookNotifyStatus(context.Background(), updatecheck.Options{ + CurrentVersion: currentVersion(), + AllowNetwork: allowNetwork, + Timeout: timeout, + }) + shouldNotify := hookNotifyShouldNotify(status) + if !shouldNotify { + switch format { + case "", "json": + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(hookNotifyPayload{Notify: false}) + case "text": + return nil + default: + return fmt.Errorf("unknown --format %q (want: json, text)", format) + } + } + payload := hookNotifyPayload{ + Notify: true, + LatestVersion: status.LatestVersion, + Title: fmt.Sprintf("cymbal %s is available", status.LatestVersion), + Body: fmt.Sprintf("Update: %s", status.Command), + Command: status.Command, + ReleaseURL: status.ReleaseURL, + } + var writeErr error + switch format { + case "", "json": + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + writeErr = enc.Encode(payload) + case "text": + _, writeErr = fmt.Fprintln(w, payload.Title) + if writeErr == nil { + _, writeErr = fmt.Fprintln(w, payload.Body) + } + default: + return fmt.Errorf("unknown --format %q (want: json, text)", format) + } + if writeErr != nil { + return writeErr + } + _ = hookNotifyMarkNotified(status) + return nil +} + func reminderUpdateOptions(mode string) (bool, time.Duration, error) { switch strings.TrimSpace(strings.ToLower(mode)) { case "", remindUpdateCache: diff --git a/cmd/hook_assets/opencode/cymbal-opencode.js b/cmd/hook_assets/opencode/cymbal-opencode.js index 8b8e83e..434eb81 100644 --- a/cmd/hook_assets/opencode/cymbal-opencode.js +++ b/cmd/hook_assets/opencode/cymbal-opencode.js @@ -1,9 +1,149 @@ +import { spawn } from "node:child_process" + +export const notifiedUpdateVersions = new Set() + +export function updateNotifierDisabled() { + const value = String(process.env.CYMBAL_NO_UPDATE_NOTIFIER ?? "").trim().toLowerCase() + return value === "1" || value === "true" || value === "yes" || value === "on" +} + +export function parseUpdateNotice(text) { + if (typeof text !== "string") return null + + const normalized = text.replace(/\r\n?/g, "\n") + const match = normalized.match( + /(?:^|\n)cymbal update:\n A newer version is available: ([^\n]+)\n Run: ([^\n]+)\n(?: If you can run shell commands here, run it now\.(?:\n|$))?/, + ) + if (!match) return null + + const version = match[1].trim() + const command = match[2].trim() + if (!version || !command) return null + + return { + version, + command, + title: `cymbal update available`, + body: `A newer version is available: ${version}. Run: ${command}`, + } +} + +export function appleScriptString(value) { + return String(value) + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\r", " ") + .replaceAll("\n", " ") +} + +function powerShellSingleQuotedString(value) { + return String(value).replaceAll("'", "''") +} + +export function buildNotificationCommand(platform, notice, env) { + if (!notice || typeof notice.title !== "string" || typeof notice.body !== "string") return null + + if (platform === "darwin") { + return { + command: "osascript", + args: [ + "-e", + `display notification "${appleScriptString(notice.body)}" with title "${appleScriptString(notice.title)}"`, + ], + } + } + + if (platform === "linux") { + const hasDisplay = Boolean(env && (env.DISPLAY !== undefined || env.WAYLAND_DISPLAY !== undefined)) + if (!hasDisplay) return null + + return { + command: "notify-send", + args: [ + "--app-name=cymbal", + "--urgency=normal", + "--expire-time=10000", + "--", + notice.title, + notice.body, + ], + } + } + + if (platform === "win32") { + const title = powerShellSingleQuotedString(notice.title) + const body = powerShellSingleQuotedString(notice.body) + return { + command: "powershell.exe", + args: [ + "-NoProfile", + "-WindowStyle", + "Hidden", + "-Command", + [ + "Add-Type -AssemblyName System.Windows.Forms", + "Add-Type -AssemblyName System.Drawing", + "$notify = New-Object System.Windows.Forms.NotifyIcon", + "$notify.Icon = [System.Drawing.SystemIcons]::Information", + "$notify.Visible = $true", + `$notify.BalloonTipTitle = '${title}'`, + `$notify.BalloonTipText = '${body}'`, + "$notify.ShowBalloonTip(10000)", + "Start-Sleep -Milliseconds 11000", + "$notify.Dispose()", + ].join("; "), + ], + } + } + + return null +} + +export async function showNativeNotification(notice) { + const spec = buildNotificationCommand(process.platform, notice, process.env) + if (!spec) return + + try { + const child = spawn(spec.command, spec.args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }) + child.once("error", () => {}) + child.unref() + } catch (error) { + void error + } +} + +export async function notifyUpdateFromCymbal($) { + if (updateNotifierDisabled()) return + + try { + const raw = await $`cymbal hook notify --format=json --update=cache`.quiet().nothrow().text() + const payload = JSON.parse(raw.trim() || "{}") + if (!payload.notify || !payload.latestVersion) return + if (notifiedUpdateVersions.has(payload.latestVersion)) return + + notifiedUpdateVersions.add(payload.latestVersion) + await showNativeNotification({ + version: payload.latestVersion, + title: payload.title, + body: payload.body, + command: payload.command, + }) + } catch (error) { + void error + } +} + export default async ({ $ }) => ({ "experimental.chat.system.transform": async (_input, output) => { try { const reminder = await $`cymbal hook remind --format=text --update=if-stale`.text() const text = reminder.trim() if (text) output.system.push(text) + await notifyUpdateFromCymbal($) } catch (error) { void error } diff --git a/cmd/hook_assets/opencode/cymbal-opencode.test.mjs b/cmd/hook_assets/opencode/cymbal-opencode.test.mjs new file mode 100644 index 0000000..87e8183 --- /dev/null +++ b/cmd/hook_assets/opencode/cymbal-opencode.test.mjs @@ -0,0 +1,190 @@ +import test from "node:test" +import assert from "node:assert/strict" + +import { + appleScriptString, + buildNotificationCommand, + parseUpdateNotice, + updateNotifierDisabled, +} from "./cymbal-opencode.js" + +test("parseUpdateNotice extracts version and command from valid block", () => { + const text = [ + "hello", + "cymbal update:", + " A newer version is available: 1.2.3", + " Run: cymbal update", + " If you can run shell commands here, run it now.", + "", + ].join("\n") + + assert.deepStrictEqual(parseUpdateNotice(text), { + version: "1.2.3", + command: "cymbal update", + title: "cymbal update available", + body: "A newer version is available: 1.2.3. Run: cymbal update", + }) +}) + +test("parseUpdateNotice returns null for text without update block", () => { + assert.equal(parseUpdateNotice("nothing to see here"), null) +}) + +test("parseUpdateNotice returns null for empty or invalid input", () => { + assert.equal(parseUpdateNotice(""), null) + assert.equal(parseUpdateNotice(null), null) + assert.equal(parseUpdateNotice(undefined), null) + assert.equal(parseUpdateNotice("cymbal update:\n Run: missing version"), null) +}) + +test("buildNotificationCommand returns osascript for darwin", () => { + assert.deepStrictEqual( + buildNotificationCommand( + "darwin", + { title: "Update", body: 'Run "cymbal" \\ now\nplease' }, + {}, + ), + { + command: "osascript", + args: [ + "-e", + 'display notification "Run \\\"cymbal\\\" \\\\ now please" with title "Update"', + ], + }, + ) +}) + +test("buildNotificationCommand returns null for linux without DISPLAY or WAYLAND_DISPLAY", () => { + assert.equal( + buildNotificationCommand("linux", { title: "Update", body: "Body" }, {}), + null, + ) +}) + +test("buildNotificationCommand returns notify-send for linux with DISPLAY", () => { + assert.deepStrictEqual( + buildNotificationCommand("linux", { title: "Update", body: "Body" }, { DISPLAY: ":0" }), + { + command: "notify-send", + args: [ + "--app-name=cymbal", + "--urgency=normal", + "--expire-time=10000", + "--", + "Update", + "Body", + ], + }, + ) +}) + +test("buildNotificationCommand returns notify-send for linux with WAYLAND_DISPLAY", () => { + assert.deepStrictEqual( + buildNotificationCommand("linux", { title: "Update", body: "Body" }, { WAYLAND_DISPLAY: "wayland-0" }), + { + command: "notify-send", + args: [ + "--app-name=cymbal", + "--urgency=normal", + "--expire-time=10000", + "--", + "Update", + "Body", + ], + }, + ) +}) + +test("buildNotificationCommand returns powershell for win32", () => { + assert.deepStrictEqual( + buildNotificationCommand("win32", { title: "O'Reilly", body: "Line 1\nLine 2" }, {}), + { + command: "powershell.exe", + args: [ + "-NoProfile", + "-WindowStyle", + "Hidden", + "-Command", + [ + "Add-Type -AssemblyName System.Windows.Forms", + "Add-Type -AssemblyName System.Drawing", + "$notify = New-Object System.Windows.Forms.NotifyIcon", + "$notify.Icon = [System.Drawing.SystemIcons]::Information", + "$notify.Visible = $true", + "$notify.BalloonTipTitle = 'O''Reilly'", + "$notify.BalloonTipText = 'Line 1\nLine 2'", + "$notify.ShowBalloonTip(10000)", + "Start-Sleep -Milliseconds 11000", + "$notify.Dispose()", + ].join("; "), + ], + }, + ) +}) + +test("buildNotificationCommand returns null for unsupported platform", () => { + assert.equal( + buildNotificationCommand("freebsd", { title: "Update", body: "Body" }, {}), + null, + ) +}) + +test("buildNotificationCommand returns null for invalid notice object", () => { + assert.equal(buildNotificationCommand("darwin", null, {}), null) + assert.equal(buildNotificationCommand("darwin", {}, {}), null) + assert.equal(buildNotificationCommand("darwin", { title: "Only title" }, {}), null) + assert.equal(buildNotificationCommand("darwin", { body: "Only body" }, {}), null) +}) + +test("updateNotifierDisabled returns true for enabled disable values", () => { + const original = process.env.CYMBAL_NO_UPDATE_NOTIFIER + + try { + for (const value of ["1", "true", "yes", "on"]) { + process.env.CYMBAL_NO_UPDATE_NOTIFIER = value + assert.equal(updateNotifierDisabled(), true) + } + } finally { + if (original === undefined) { + delete process.env.CYMBAL_NO_UPDATE_NOTIFIER + } else { + process.env.CYMBAL_NO_UPDATE_NOTIFIER = original + } + } +}) + +test("updateNotifierDisabled returns false for unset env", () => { + const original = process.env.CYMBAL_NO_UPDATE_NOTIFIER + + try { + delete process.env.CYMBAL_NO_UPDATE_NOTIFIER + assert.equal(updateNotifierDisabled(), false) + } finally { + if (original === undefined) { + delete process.env.CYMBAL_NO_UPDATE_NOTIFIER + } else { + process.env.CYMBAL_NO_UPDATE_NOTIFIER = original + } + } +}) + +test("updateNotifierDisabled returns false for disabled-looking values", () => { + const original = process.env.CYMBAL_NO_UPDATE_NOTIFIER + + try { + for (const value of ["0", "false", "no", "off"]) { + process.env.CYMBAL_NO_UPDATE_NOTIFIER = value + assert.equal(updateNotifierDisabled(), false) + } + } finally { + if (original === undefined) { + delete process.env.CYMBAL_NO_UPDATE_NOTIFIER + } else { + process.env.CYMBAL_NO_UPDATE_NOTIFIER = original + } + } +}) + +test("appleScriptString escapes backslashes, quotes, and newlines", () => { + assert.equal(appleScriptString('path \\ "quoted"\nline\r\nnext'), 'path \\\\ \\\"quoted\\\" line next') +}) diff --git a/cmd/hook_test.go b/cmd/hook_test.go index d7527d2..3e7c2b9 100644 --- a/cmd/hook_test.go +++ b/cmd/hook_test.go @@ -394,6 +394,140 @@ func TestEmitRemindSkipsUpdateWhenNotifierDisabled(t *testing.T) { } } +func TestEmitHookNotifyJSONIncludesPayload(t *testing.T) { + oldStatus, oldShouldNotify, oldMarkNotified := hookNotifyStatus, hookNotifyShouldNotify, hookNotifyMarkNotified + defer func() { + hookNotifyStatus = oldStatus + hookNotifyShouldNotify = oldShouldNotify + hookNotifyMarkNotified = oldMarkNotified + }() + + markCalled := false + hookNotifyStatus = func(ctx context.Context, opts updatecheck.Options) (updatecheck.Status, error) { + return updatecheck.Status{ + Available: true, + LatestVersion: "v0.13.0", + Command: "brew upgrade 1broseidon/tap/cymbal", + ReleaseURL: "https://github.com/1broseidon/cymbal/releases/latest", + }, nil + } + hookNotifyShouldNotify = func(status updatecheck.Status) bool { return true } + hookNotifyMarkNotified = func(status updatecheck.Status) error { + markCalled = true + return nil + } + + var buf bytes.Buffer + if err := emitHookNotify(&buf, "json", "cache"); err != nil { + t.Fatal(err) + } + if !markCalled { + t.Fatal("expected notification mark to be recorded") + } + var out hookNotifyPayload + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("json output must be valid: %v\n%s", err, buf.String()) + } + if !out.Notify || out.LatestVersion != "v0.13.0" || out.Title != "cymbal v0.13.0 is available" { + t.Fatalf("unexpected payload: %+v", out) + } + if out.Body != "Update: brew upgrade 1broseidon/tap/cymbal" { + t.Fatalf("unexpected body: %+v", out) + } + if out.Command != "brew upgrade 1broseidon/tap/cymbal" || out.ReleaseURL != "https://github.com/1broseidon/cymbal/releases/latest" { + t.Fatalf("unexpected command metadata: %+v", out) + } +} + +func TestEmitHookNotifyJSONFalseWhenThrottled(t *testing.T) { + oldStatus, oldShouldNotify, oldMarkNotified := hookNotifyStatus, hookNotifyShouldNotify, hookNotifyMarkNotified + defer func() { + hookNotifyStatus = oldStatus + hookNotifyShouldNotify = oldShouldNotify + hookNotifyMarkNotified = oldMarkNotified + }() + + markCalled := false + hookNotifyStatus = func(ctx context.Context, opts updatecheck.Options) (updatecheck.Status, error) { + return updatecheck.Status{Available: true, LatestVersion: "v0.13.0"}, nil + } + hookNotifyShouldNotify = func(status updatecheck.Status) bool { return false } + hookNotifyMarkNotified = func(status updatecheck.Status) error { + markCalled = true + return nil + } + + var buf bytes.Buffer + if err := emitHookNotify(&buf, "json", "cache"); err != nil { + t.Fatal(err) + } + if markCalled { + t.Fatal("mark should not be called when throttled") + } + var out hookNotifyPayload + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("json output must be valid: %v\n%s", err, buf.String()) + } + if out.Notify { + t.Fatalf("expected notify=false when throttled, got %+v", out) + } +} + +func TestEmitHookNotifyTextEmptyWhenNoUpdate(t *testing.T) { + oldStatus, oldShouldNotify, oldMarkNotified := hookNotifyStatus, hookNotifyShouldNotify, hookNotifyMarkNotified + defer func() { + hookNotifyStatus = oldStatus + hookNotifyShouldNotify = oldShouldNotify + hookNotifyMarkNotified = oldMarkNotified + }() + + hookNotifyStatus = func(ctx context.Context, opts updatecheck.Options) (updatecheck.Status, error) { + return updatecheck.Status{Available: false}, nil + } + hookNotifyShouldNotify = func(status updatecheck.Status) bool { return false } + hookNotifyMarkNotified = func(status updatecheck.Status) error { + t.Fatal("mark should not be called when no update is available") + return nil + } + + var buf bytes.Buffer + if err := emitHookNotify(&buf, "text", "cache"); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("expected empty text output, got %q", buf.String()) + } +} + +func TestEmitHookNotifyHonorsNotifierOptOut(t *testing.T) { + oldStatus, oldMarkNotified := hookNotifyStatus, hookNotifyMarkNotified + defer func() { + hookNotifyStatus = oldStatus + hookNotifyMarkNotified = oldMarkNotified + }() + + t.Setenv("CYMBAL_NO_UPDATE_NOTIFIER", "1") + hookNotifyStatus = func(ctx context.Context, opts updatecheck.Options) (updatecheck.Status, error) { + return updatecheck.Status{Available: true, LatestVersion: "v0.13.0"}, nil + } + hookNotifyMarkNotified = func(status updatecheck.Status) error { + t.Fatal("mark should not be called when notifier is disabled") + return nil + } + + var buf bytes.Buffer + if err := emitHookNotify(&buf, "json", "if-stale"); err != nil { + t.Fatal(err) + } + var out hookNotifyPayload + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("json output must be valid: %v\n%s", err, buf.String()) + } + if out.Notify { + t.Fatalf("expected notify=false when notifier disabled, got %+v", out) + } +} + // ── claude-code install / uninstall round-trip ── func TestClaudeCodeInstallIsIdempotent(t *testing.T) { diff --git a/docs/AGENT_HOOKS.md b/docs/AGENT_HOOKS.md index c3d6a56..37289b6 100644 --- a/docs/AGENT_HOOKS.md +++ b/docs/AGENT_HOOKS.md @@ -215,9 +215,13 @@ What it does: - The plugin soft-nudges bash `rg` / `grep` / `find` / `fd`-style commands back toward cymbal-first navigation before the shell runs them on non-Windows shells +- When an update is available, the plugin shows a **native OS notification** + (macOS Notification Center, Linux `notify-send`, or Windows system tray) + so users see it regardless of whether they're in TUI or Desktop mode - Update guidance stays fresh automatically, but **cymbal still never self-updates by default** — it only surfaces the explicit update command to run +- Set `CYMBAL_NO_UPDATE_NOTIFIER=1` to disable all update notifications Examples: diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b7629f5..ce73df5 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -302,6 +302,26 @@ cymbal hook remind [flags] - `--update=if-stale` performs a bounded live update check only when cache is stale or missing. - Reminder output can surface update guidance, but cymbal still never self-updates by default. +### `cymbal hook notify` + +Emit a structured update notification payload for agent plugins that want to +surface update notices outside hidden system context. + +```sh +cymbal hook notify [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format ` | `json` (default) or `text` | +| `--update ` | `cache` (default) or `if-stale` | + +- Returns `{"notify": true, ...}` with version and command when an update is available and the notification throttle allows it. +- Returns `{"notify": false}` when no update is available or the user was already notified recently. +- `text` format prints a plain notice; empty output when no notice is due. +- Respects `CYMBAL_NO_UPDATE_NOTIFIER`. +- Uses cymbal's per-version notification throttle (24h TTL). + ### `cymbal hook nudge` Inspect a would-be shell command and, if it looks like code navigation through