Skip to content

Commit 056b9c4

Browse files
authored
Merge pull request #22 from dmoliveira/my_opencode-doctor-polish
Add /notify and /digest doctor diagnostics with JSON output
2 parents 62feba0 + 6987946 commit 056b9c4

File tree

7 files changed

+227
-2
lines changed

7 files changed

+227
-2
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ install-test: ## Run installer smoke test in temp HOME
2525
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" profile lean; \
2626
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/plugin_command.py" doctor --json; \
2727
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" status; \
28+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/notify_command.py" doctor --json; \
2829
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" run --reason install-test; \
2930
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" show; \
31+
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/session_digest.py" doctor --json; \
3032
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/telemetry_command.py" status; \
3133
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/telemetry_command.py" doctor --json; \
3234
HOME="$$TMP_HOME" python3 "$$TMP_HOME/.config/opencode/my_opencode/scripts/post_session_command.py" status; \

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Use these directly in OpenCode:
169169
```text
170170
/notify status
171171
/notify help
172+
/notify doctor
173+
/notify doctor --json
172174
/notify profile all
173175
/notify profile quiet
174176
/notify profile focus
@@ -188,6 +190,8 @@ Autocomplete-friendly shortcuts:
188190

189191
```text
190192
/notify-help
193+
/notify-doctor
194+
/notify-doctor-json
191195
/notify-profile-all
192196
/notify-profile-focus
193197
/notify-sound-only
@@ -208,6 +212,8 @@ Use these directly in OpenCode:
208212
/digest run --reason manual
209213
/digest run --reason manual --run-post
210214
/digest show
215+
/digest doctor
216+
/digest doctor --json
211217
```
212218

213219
Autocomplete-friendly shortcuts:
@@ -216,6 +222,8 @@ Autocomplete-friendly shortcuts:
216222
/digest-run
217223
/digest-run-post
218224
/digest-show
225+
/digest-doctor
226+
/digest-doctor-json
219227
```
220228

221229
The digest command writes to `~/.config/opencode/digests/last-session.json` by default.

install.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ if [ "$SKIP_SELF_CHECK" = false ]; then
6868
python3 "$INSTALL_DIR/scripts/mcp_command.py" status
6969
python3 "$INSTALL_DIR/scripts/plugin_command.py" status
7070
python3 "$INSTALL_DIR/scripts/notify_command.py" status
71+
python3 "$INSTALL_DIR/scripts/notify_command.py" doctor
7172
python3 "$INSTALL_DIR/scripts/session_digest.py" show || true
73+
python3 "$INSTALL_DIR/scripts/session_digest.py" doctor
7274
python3 "$INSTALL_DIR/scripts/telemetry_command.py" status
7375
python3 "$INSTALL_DIR/scripts/post_session_command.py" status
7476
python3 "$INSTALL_DIR/scripts/policy_command.py" status
@@ -94,9 +96,11 @@ printf " /plugin status\n"
9496
printf " /plugin doctor\n"
9597
printf " /notify status\n"
9698
printf " /notify profile focus\n"
99+
printf " /notify doctor\n"
97100
printf " /digest run --reason manual\n"
98101
printf " /digest-run-post\n"
99102
printf " /digest show\n"
103+
printf " /digest doctor\n"
100104
printf " /telemetry status\n"
101105
printf " /telemetry profile local\n"
102106
printf " /post-session status\n"

opencode.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@
114114
"description": "Show notification command usage",
115115
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" help`\nShow only the command output."
116116
},
117+
"notify-doctor": {
118+
"description": "Run notification diagnostics",
119+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" doctor`\nShow only the command output."
120+
},
121+
"notify-doctor-json": {
122+
"description": "Run notification diagnostics in JSON",
123+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" doctor --json`\nShow only the command output."
124+
},
117125
"notify-profile-all": {
118126
"description": "Enable all notification channels and events",
119127
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/notify_command.py\" profile all`\nShow only the command output."
@@ -146,6 +154,14 @@
146154
"description": "Show latest saved session digest",
147155
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" show`\nShow only the command output."
148156
},
157+
"digest-doctor": {
158+
"description": "Run digest diagnostics",
159+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" doctor`\nShow only the command output."
160+
},
161+
"digest-doctor-json": {
162+
"description": "Run digest diagnostics in JSON",
163+
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/session_digest.py\" doctor --json`\nShow only the command output."
164+
},
149165
"post-session": {
150166
"description": "Manage post-session hook config (status|enable|disable|set)",
151167
"template": "!`python3 \"$HOME/.config/opencode/my_opencode/scripts/post_session_command.py\" $ARGUMENTS`\nShow only the command output."

scripts/notify_command.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,91 @@ def write_config(config_path: Path, state: dict) -> None:
134134

135135
def usage() -> int:
136136
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>"
137+
"usage: /notify status | /notify help | /notify doctor [--json] | /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>"
138138
)
139139
return 2
140140

141141

142+
def collect_doctor(config_path: Path, state: dict) -> dict:
143+
problems: list[str] = []
144+
warnings: list[str] = []
145+
146+
if not config_path.exists():
147+
warnings.append("notification config file not found yet (using defaults)")
148+
149+
if not state["enabled"]:
150+
warnings.append("global notifications are disabled")
151+
152+
if not state["sound"]["enabled"] and not state["visual"]["enabled"]:
153+
warnings.append("both sound and visual channels are disabled")
154+
155+
enabled_events = [name for name in EVENTS if state["events"][name]]
156+
if not enabled_events:
157+
warnings.append("all events are disabled")
158+
159+
for event in EVENTS:
160+
if state["events"][event] and not (
161+
state["channels"][event]["sound"] or state["channels"][event]["visual"]
162+
):
163+
warnings.append(
164+
f"event {event} is enabled but both per-event channels are off"
165+
)
166+
167+
return {
168+
"result": "PASS" if not problems else "FAIL",
169+
"config": str(config_path),
170+
"enabled": state["enabled"],
171+
"sound_enabled": state["sound"]["enabled"],
172+
"visual_enabled": state["visual"]["enabled"],
173+
"events": state["events"],
174+
"channels": state["channels"],
175+
"warnings": warnings,
176+
"problems": problems,
177+
"quick_fixes": [
178+
"run /notify profile focus for low-noise defaults",
179+
"run /notify enable visual or /notify enable sound",
180+
"run /notify enable permission or /notify enable error",
181+
]
182+
if warnings or problems
183+
else [],
184+
}
185+
186+
187+
def print_doctor(config_path: Path, state: dict, json_output: bool) -> int:
188+
report = collect_doctor(config_path, state)
189+
190+
if json_output:
191+
print(json.dumps(report, indent=2))
192+
return 0 if report["result"] == "PASS" else 1
193+
194+
print("notify doctor")
195+
print("-----------")
196+
print(f"config: {report['config']}")
197+
print(f"global: {'enabled' if report['enabled'] else 'disabled'}")
198+
print(f"sound: {'enabled' if report['sound_enabled'] else 'disabled'}")
199+
print(f"visual: {'enabled' if report['visual_enabled'] else 'disabled'}")
200+
print("events:")
201+
for event in EVENTS:
202+
print(
203+
f"- {event}: {'enabled' if state['events'][event] else 'disabled'} [sound={'on' if state['channels'][event]['sound'] else 'off'}, visual={'on' if state['channels'][event]['visual'] else 'off'}]"
204+
)
205+
206+
if report["warnings"]:
207+
print("\nwarnings:")
208+
for item in report["warnings"]:
209+
print(f"- {item}")
210+
211+
if report["problems"]:
212+
print("\nproblems:")
213+
for item in report["problems"]:
214+
print(f"- {item}")
215+
print("\nresult: FAIL")
216+
return 1
217+
218+
print("\nresult: PASS")
219+
return 0
220+
221+
142222
def print_status(config_path: Path, state: dict) -> int:
143223
print(f"all: {'enabled' if state['enabled'] else 'disabled'}")
144224
print(f"sound: {'enabled' if state['sound']['enabled'] else 'disabled'}")
@@ -215,6 +295,12 @@ def main(argv: list[str]) -> int:
215295
if argv[0] == "help":
216296
return usage()
217297

298+
if argv[0] == "doctor":
299+
json_output = len(argv) > 1 and argv[1] == "--json"
300+
if len(argv) > 1 and not json_output:
301+
return usage()
302+
return print_doctor(config_path, state, json_output)
303+
218304
if argv[0] == "profile":
219305
if len(argv) < 2:
220306
return usage()

scripts/selftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ def run_notify(*args: str) -> subprocess.CompletedProcess[str]:
220220
expect(result.returncode == 0, f"notify status failed: {result.stderr}")
221221
expect("config:" in result.stdout, "notify status should print config path")
222222

223+
result = run_notify("doctor", "--json")
224+
expect(result.returncode == 0, f"notify doctor --json failed: {result.stderr}")
225+
report = parse_json_output(result.stdout)
226+
expect(report.get("result") == "PASS", "notify doctor should pass")
227+
223228
digest_path = home / ".config" / "opencode" / "digests" / "selftest.json"
224229
digest_env = os.environ.copy()
225230
digest_env["MY_OPENCODE_DIGEST_PATH"] = str(digest_path)
@@ -248,6 +253,25 @@ def run_notify(*args: str) -> subprocess.CompletedProcess[str]:
248253
expect(result.returncode == 0, f"digest show failed: {result.stderr}")
249254
expect("reason: selftest" in result.stdout, "digest show should print reason")
250255

256+
result = subprocess.run(
257+
[
258+
sys.executable,
259+
str(DIGEST_SCRIPT),
260+
"doctor",
261+
"--path",
262+
str(digest_path),
263+
"--json",
264+
],
265+
capture_output=True,
266+
text=True,
267+
env=digest_env,
268+
check=False,
269+
cwd=REPO_ROOT,
270+
)
271+
expect(result.returncode == 0, f"digest doctor --json failed: {result.stderr}")
272+
report = parse_json_output(result.stdout)
273+
expect(report.get("result") == "PASS", "digest doctor should pass")
274+
251275
telemetry_path = home / ".config" / "opencode" / "opencode-telemetry.json"
252276
telemetry_env = os.environ.copy()
253277
telemetry_env["OPENCODE_TELEMETRY_PATH"] = str(telemetry_path)

scripts/session_digest.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def print_summary(path: Path, digest: dict) -> None:
166166

167167
def usage() -> int:
168168
print(
169-
'usage: /digest run [--reason <idle|exit|manual>] [--path <digest.json>] [--hook "command"] [--run-post] | /digest show [--path <digest.json>]'
169+
'usage: /digest run [--reason <idle|exit|manual>] [--path <digest.json>] [--hook "command"] [--run-post] | /digest show [--path <digest.json>] | /digest doctor [--path <digest.json>] [--json]'
170170
)
171171
return 2
172172

@@ -233,6 +233,89 @@ def command_show(argv: list[str]) -> int:
233233
return 0
234234

235235

236+
def collect_doctor(path: Path) -> dict:
237+
problems: list[str] = []
238+
warnings: list[str] = []
239+
240+
if not path.exists():
241+
warnings.append("digest file does not exist yet")
242+
return {
243+
"result": "PASS",
244+
"path": str(path),
245+
"exists": False,
246+
"warnings": warnings,
247+
"problems": problems,
248+
"quick_fixes": ["run /digest run --reason manual"],
249+
}
250+
251+
try:
252+
digest = json.loads(path.read_text(encoding="utf-8"))
253+
except Exception as exc:
254+
problems.append(f"failed to parse digest JSON: {exc}")
255+
return {
256+
"result": "FAIL",
257+
"path": str(path),
258+
"exists": True,
259+
"warnings": warnings,
260+
"problems": problems,
261+
"quick_fixes": ["run /digest run --reason manual to regenerate"],
262+
}
263+
264+
for field in ("timestamp", "reason", "cwd", "git"):
265+
if field not in digest:
266+
warnings.append(f"missing digest field: {field}")
267+
268+
git_block = digest.get("git")
269+
if not isinstance(git_block, dict):
270+
warnings.append("git block is missing or invalid")
271+
else:
272+
if git_block.get("branch") is None:
273+
warnings.append("git branch is unknown")
274+
275+
return {
276+
"result": "PASS" if not problems else "FAIL",
277+
"path": str(path),
278+
"exists": True,
279+
"warnings": warnings,
280+
"problems": problems,
281+
"quick_fixes": ["run /digest run --reason manual"] if warnings else [],
282+
}
283+
284+
285+
def command_doctor(argv: list[str]) -> int:
286+
path_value = parse_option(argv, "--path")
287+
json_output = "--json" in argv
288+
if len([x for x in argv if x == "--json"]) > 1:
289+
return usage()
290+
291+
path = Path(path_value).expanduser() if path_value else DEFAULT_DIGEST_PATH
292+
report = collect_doctor(path)
293+
294+
if json_output:
295+
print(json.dumps(report, indent=2))
296+
return 0 if report["result"] == "PASS" else 1
297+
298+
print("digest doctor")
299+
print("------------")
300+
print(f"path: {report['path']}")
301+
print(f"exists: {'yes' if report['exists'] else 'no'}")
302+
303+
if report["warnings"]:
304+
print("\nwarnings:")
305+
for item in report["warnings"]:
306+
print(f"- {item}")
307+
308+
if report["problems"]:
309+
print("\nproblems:")
310+
for item in report["problems"]:
311+
print(f"- {item}")
312+
print("\nresult: FAIL")
313+
return 1
314+
315+
print("\nresult: PASS")
316+
return 0
317+
318+
236319
def main(argv: list[str]) -> int:
237320
if not argv:
238321
return usage()
@@ -246,6 +329,8 @@ def main(argv: list[str]) -> int:
246329
return command_run(rest)
247330
if command == "show":
248331
return command_show(rest)
332+
if command == "doctor":
333+
return command_doctor(rest)
249334
return usage()
250335

251336

0 commit comments

Comments
 (0)