Skip to content

Commit 147df7f

Browse files
authored
Merge pull request #17 from dmoliveira/my_opencode-notification-controls
Add /notify command for granular notification controls
2 parents c14d059 + 4f20830 commit 147df7f

File tree

6 files changed

+380
-2
lines changed

6 files changed

+380
-2
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ install-test: ## Run installer smoke test in temp HOME
2323
HOME="$$TMP_HOME" REPO_URL="$(PWD)" REPO_REF="$$(git rev-parse --abbrev-ref HEAD)" ./install.sh --skip-self-check; \
2424
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/mcp_command.py" status; \
2525
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" profile lean; \
26-
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" doctor --json
26+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" doctor --json; \
27+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status
2728

2829
release: ## Create and publish release (VERSION=0.1.1)
2930
@test -n "$(VERSION)" || (echo "VERSION is required, eg: make release VERSION=0.1.1" && exit 2)

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ This repo gives you a clean, portable OpenCode setup with fast MCP controls insi
1616

1717
- 🧠 Built-in `/mcp` command for `status`, `enable`, and `disable`.
1818
- 🎛️ Built-in `/plugin` command to enable or disable plugins without editing JSON.
19+
- 🔔 Built-in `/notify` command to tune notification behavior by level (all, channel, event, per-channel event).
1920
- 💸 Better token control by enabling `context7` / `gh_grep` only on demand.
2021
- 🔒 Autonomous-friendly permissions for trusted project paths.
2122
- 🔁 Easy updates by rerunning the installer.
@@ -156,11 +157,50 @@ For Morph Fast Apply, set `MORPH_API_KEY` in your shell before enabling `morph`.
156157

157158
For WakaTime, configure `~/.wakatime.cfg` with your `api_key` before enabling `wakatime`.
158159

160+
## Notification control inside OpenCode 🔔
161+
162+
Use these directly in OpenCode:
163+
164+
```text
165+
/notify status
166+
/notify help
167+
/notify profile all
168+
/notify profile quiet
169+
/notify profile focus
170+
/notify profile sound-only
171+
/notify profile visual-only
172+
/notify enable all
173+
/notify disable all
174+
/notify enable sound
175+
/notify disable visual
176+
/notify disable complete
177+
/notify enable permission
178+
/notify channel question sound off
179+
/notify channel error visual on
180+
```
181+
182+
Autocomplete-friendly shortcuts:
183+
184+
```text
185+
/notify-help
186+
/notify-profile-all
187+
/notify-profile-focus
188+
/notify-sound-only
189+
/notify-visual-only
190+
```
191+
192+
`/notify` writes preferences to `~/.config/opencode/opencode-notifications.json` with controls at four levels:
193+
- global: `enabled`
194+
- channel: `sound.enabled`, `visual.enabled`
195+
- event: `events.<type>`
196+
- per-event channel: `channels.<type>.sound|visual`
197+
159198
## Repo layout 📦
160199

161200
- `opencode.json` - global OpenCode config (linked to default path)
162201
- `scripts/mcp_command.py` - backend script for `/mcp`
163202
- `scripts/plugin_command.py` - backend script for `/plugin`
203+
- `scripts/notify_command.py` - backend script for `/notify`
164204
- `install.sh` - one-step installer/updater
165205
- `Makefile` - common maintenance commands (`make help`)
166206
- `.github/workflows/ci.yml` - CI checks and installer smoke test

install.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ if [ -n "$REPO_REF" ]; then
6060
git -C "$INSTALL_DIR" checkout "$REPO_REF"
6161
fi
6262

63-
chmod +x "$INSTALL_DIR/scripts/mcp_command.py" "$INSTALL_DIR/scripts/plugin_command.py"
63+
chmod +x "$INSTALL_DIR/scripts/mcp_command.py" "$INSTALL_DIR/scripts/plugin_command.py" "$INSTALL_DIR/scripts/notify_command.py"
6464
ln -sfn "$INSTALL_DIR/opencode.json" "$CONFIG_PATH"
6565

6666
if [ "$SKIP_SELF_CHECK" = false ]; then
6767
printf "\nRunning self-check...\n"
6868
python3 "$INSTALL_DIR/scripts/mcp_command.py" status
6969
python3 "$INSTALL_DIR/scripts/plugin_command.py" status
70+
python3 "$INSTALL_DIR/scripts/notify_command.py" status
7071
if ! python3 "$INSTALL_DIR/scripts/plugin_command.py" doctor; then
7172
if [ "$NON_INTERACTIVE" = true ]; then
7273
printf "\nSelf-check failed in non-interactive mode.\n" >&2
@@ -87,6 +88,8 @@ printf " /mcp enable context7\n"
8788
printf " /mcp disable context7\n"
8889
printf " /plugin status\n"
8990
printf " /plugin doctor\n"
91+
printf " /notify status\n"
92+
printf " /notify profile focus\n"
9093
printf " /setup-keys\n"
9194
printf " /plugin enable supermemory\n"
9295
printf " /plugin disable supermemory\n"

opencode.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@
105105
"plugin-profile-lean": {
106106
"description": "Switch plugin profile to lean",
107107
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/plugin_command.py\" profile lean`\nShow only the command output."
108+
},
109+
"notify": {
110+
"description": "Manage notification controls (status|profile|enable|disable|channel)",
111+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" $ARGUMENTS`\nShow only the command output."
112+
},
113+
"notify-help": {
114+
"description": "Show notification command usage",
115+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" help`\nShow only the command output."
116+
},
117+
"notify-profile-all": {
118+
"description": "Enable all notification channels and events",
119+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile all`\nShow only the command output."
120+
},
121+
"notify-profile-focus": {
122+
"description": "Keep only visual alerts for important events",
123+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile focus`\nShow only the command output."
124+
},
125+
"notify-sound-only": {
126+
"description": "Switch notifications to sound only",
127+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile sound-only`\nShow only the command output."
128+
},
129+
"notify-visual-only": {
130+
"description": "Switch notifications to visual only",
131+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile visual-only`\nShow only the command output."
108132
}
109133
},
110134
"plugin": [

scripts/notify_command.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import os
5+
import sys
6+
from pathlib import Path
7+
8+
9+
DEFAULT_CONFIG_PATH = Path(
10+
os.environ.get(
11+
"OPENCODE_NOTIFICATIONS_PATH", "~/.config/opencode/opencode-notifications.json"
12+
)
13+
).expanduser()
14+
15+
EVENTS = ("complete", "error", "permission", "question")
16+
CHANNELS = ("sound", "visual")
17+
18+
PROFILE_MAP = {
19+
"all": {
20+
"enabled": True,
21+
"sound": True,
22+
"visual": True,
23+
"events": {name: True for name in EVENTS},
24+
"channels": {name: {"sound": True, "visual": True} for name in EVENTS},
25+
},
26+
"quiet": {
27+
"enabled": True,
28+
"sound": False,
29+
"visual": True,
30+
"events": {name: True for name in EVENTS},
31+
"channels": {name: {"sound": False, "visual": True} for name in EVENTS},
32+
},
33+
"focus": {
34+
"enabled": True,
35+
"sound": False,
36+
"visual": True,
37+
"events": {
38+
"complete": False,
39+
"error": True,
40+
"permission": True,
41+
"question": True,
42+
},
43+
"channels": {
44+
"complete": {"sound": False, "visual": False},
45+
"error": {"sound": False, "visual": True},
46+
"permission": {"sound": False, "visual": True},
47+
"question": {"sound": False, "visual": True},
48+
},
49+
},
50+
"sound-only": {
51+
"enabled": True,
52+
"sound": True,
53+
"visual": False,
54+
"events": {name: True for name in EVENTS},
55+
"channels": {name: {"sound": True, "visual": False} for name in EVENTS},
56+
},
57+
"visual-only": {
58+
"enabled": True,
59+
"sound": False,
60+
"visual": True,
61+
"events": {name: True for name in EVENTS},
62+
"channels": {name: {"sound": False, "visual": True} for name in EVENTS},
63+
},
64+
}
65+
66+
67+
def default_state() -> dict:
68+
return {
69+
"enabled": True,
70+
"sound": {"enabled": True},
71+
"visual": {"enabled": True},
72+
"events": {name: True for name in EVENTS},
73+
"channels": {name: {"sound": True, "visual": True} for name in EVENTS},
74+
}
75+
76+
77+
def to_bool(value, fallback: bool) -> bool:
78+
if isinstance(value, bool):
79+
return value
80+
return fallback
81+
82+
83+
def load_config(config_path: Path) -> dict:
84+
if not config_path.exists():
85+
return default_state()
86+
87+
data = json.loads(config_path.read_text(encoding="utf-8"))
88+
state = default_state()
89+
90+
state["enabled"] = to_bool(data.get("enabled"), state["enabled"])
91+
92+
if isinstance(data.get("sound"), dict):
93+
state["sound"]["enabled"] = to_bool(
94+
data["sound"].get("enabled"), state["sound"]["enabled"]
95+
)
96+
97+
if isinstance(data.get("visual"), dict):
98+
state["visual"]["enabled"] = to_bool(
99+
data["visual"].get("enabled"), state["visual"]["enabled"]
100+
)
101+
102+
if isinstance(data.get("events"), dict):
103+
for event in EVENTS:
104+
if event in data["events"]:
105+
state["events"][event] = to_bool(
106+
data["events"][event], state["events"][event]
107+
)
108+
109+
if isinstance(data.get("channels"), dict):
110+
for event in EVENTS:
111+
entry = data["channels"].get(event)
112+
if not isinstance(entry, dict):
113+
continue
114+
for channel in CHANNELS:
115+
if channel in entry:
116+
state["channels"][event][channel] = to_bool(
117+
entry[channel], state["channels"][event][channel]
118+
)
119+
120+
return state
121+
122+
123+
def write_config(config_path: Path, state: dict) -> None:
124+
config_path.parent.mkdir(parents=True, exist_ok=True)
125+
config = {
126+
"enabled": state["enabled"],
127+
"sound": {"enabled": state["sound"]["enabled"]},
128+
"visual": {"enabled": state["visual"]["enabled"]},
129+
"events": state["events"],
130+
"channels": state["channels"],
131+
}
132+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
133+
134+
135+
def usage() -> int:
136+
print(
137+
"usage: /notify status | /notify help | /notify profile <all|quiet|focus|sound-only|visual-only> | /notify enable <all|sound|visual|complete|error|permission|question> | /notify disable <all|sound|visual|complete|error|permission|question> | /notify channel <complete|error|permission|question> <sound|visual> <on|off>"
138+
)
139+
return 2
140+
141+
142+
def print_status(config_path: Path, state: dict) -> int:
143+
print(f"all: {'enabled' if state['enabled'] else 'disabled'}")
144+
print(f"sound: {'enabled' if state['sound']['enabled'] else 'disabled'}")
145+
print(f"visual: {'enabled' if state['visual']['enabled'] else 'disabled'}")
146+
print("events:")
147+
for event in EVENTS:
148+
enabled = state["events"][event]
149+
print(
150+
f"- {event}: {'enabled' if enabled else 'disabled'} [sound={'on' if state['channels'][event]['sound'] else 'off'}, visual={'on' if state['channels'][event]['visual'] else 'off'}]"
151+
)
152+
print(f"config: {config_path}")
153+
return 0
154+
155+
156+
def apply_profile(state: dict, profile: str) -> int:
157+
if profile not in PROFILE_MAP:
158+
return usage()
159+
160+
selected = PROFILE_MAP[profile]
161+
state["enabled"] = selected["enabled"]
162+
state["sound"]["enabled"] = selected["sound"]
163+
state["visual"]["enabled"] = selected["visual"]
164+
165+
for event in EVENTS:
166+
state["events"][event] = selected["events"][event]
167+
state["channels"][event]["sound"] = selected["channels"][event]["sound"]
168+
state["channels"][event]["visual"] = selected["channels"][event]["visual"]
169+
170+
print(f"profile: {profile}")
171+
return 0
172+
173+
174+
def set_toggle(state: dict, action: str, target: str) -> int:
175+
value = action == "enable"
176+
177+
if target == "all":
178+
state["enabled"] = value
179+
print(f"all: {'enabled' if value else 'disabled'}")
180+
return 0
181+
182+
if target in CHANNELS:
183+
state[target]["enabled"] = value
184+
print(f"{target}: {'enabled' if value else 'disabled'}")
185+
return 0
186+
187+
if target in EVENTS:
188+
state["events"][target] = value
189+
print(f"{target}: {'enabled' if value else 'disabled'}")
190+
return 0
191+
192+
return usage()
193+
194+
195+
def set_channel(state: dict, event: str, channel: str, value_text: str) -> int:
196+
if (
197+
event not in EVENTS
198+
or channel not in CHANNELS
199+
or value_text not in ("on", "off")
200+
):
201+
return usage()
202+
value = value_text == "on"
203+
state["channels"][event][channel] = value
204+
print(f"{event}.{channel}: {'on' if value else 'off'}")
205+
return 0
206+
207+
208+
def main(argv: list[str]) -> int:
209+
config_path = DEFAULT_CONFIG_PATH
210+
state = load_config(config_path)
211+
212+
if not argv or argv[0] == "status":
213+
return print_status(config_path, state)
214+
215+
if argv[0] == "help":
216+
return usage()
217+
218+
if argv[0] == "profile":
219+
if len(argv) < 2:
220+
return usage()
221+
code = apply_profile(state, argv[1])
222+
if code != 0:
223+
return code
224+
write_config(config_path, state)
225+
print(f"config: {config_path}")
226+
return 0
227+
228+
if argv[0] in ("enable", "disable"):
229+
if len(argv) < 2:
230+
return usage()
231+
code = set_toggle(state, argv[0], argv[1])
232+
if code != 0:
233+
return code
234+
write_config(config_path, state)
235+
print(f"config: {config_path}")
236+
return 0
237+
238+
if argv[0] == "channel":
239+
if len(argv) < 4:
240+
return usage()
241+
code = set_channel(state, argv[1], argv[2], argv[3])
242+
if code != 0:
243+
return code
244+
write_config(config_path, state)
245+
print(f"config: {config_path}")
246+
return 0
247+
248+
return usage()
249+
250+
251+
if __name__ == "__main__":
252+
try:
253+
raise SystemExit(main(sys.argv[1:]))
254+
except Exception as exc:
255+
print(f"error: {exc}")
256+
raise SystemExit(1)

0 commit comments

Comments
 (0)