Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
99 changes: 96 additions & 3 deletions cmd/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand All @@ -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 <agent>'
The agent-agnostic subcommands are nudge, remind, and notify. Use 'hook install <agent>'
to wire them into your agent's native hook points.`,
}

Expand Down Expand Up @@ -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 <agent>",
Short: "Install cymbal hooks into the given agent (claude-code, opencode)",
Expand Down Expand Up @@ -141,13 +165,16 @@ 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")
hookUninstallCmd.Flags().Bool("dry-run", false, "show intended changes without writing")

hookCmd.AddCommand(hookNudgeCmd)
hookCmd.AddCommand(hookRemindCmd)
hookCmd.AddCommand(hookNotifyCmd)
hookCmd.AddCommand(hookInstallCmd)
hookCmd.AddCommand(hookUninstallCmd)
rootCmd.AddCommand(hookCmd)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
140 changes: 140 additions & 0 deletions cmd/hook_assets/opencode/cymbal-opencode.js
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
Phototonic marked this conversation as resolved.
const text = reminder.trim()
if (text) output.system.push(text)
await notifyUpdateFromCymbal($)
} catch (error) {
void error
}
Expand Down
Loading
Loading