openclaw-diag-cli 0.1.3 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +83 -71
  2. package/bin/ocdiag +0 -1
  3. package/bin/openclaw-diag.js +65 -176
  4. package/diag/01_sys_health.py +0 -2
  5. package/diag/02_environment.py +32 -6
  6. package/diag/03_configuration.py +4 -1
  7. package/diag/04_gateway.py +30 -8
  8. package/diag/05_recent_errors.py +24 -14
  9. package/diag/06_cron_jobs.py +4 -41
  10. package/diag/07_performance.py +114 -42
  11. package/diag/08_sessions.py +2 -54
  12. package/diag/09_plugin_diag.py +52 -25
  13. package/diag/10_shell_history.py +28 -10
  14. package/lib/__pycache__/bundle.cpython-310.pyc +0 -0
  15. package/lib/bundle.py +6 -13
  16. package/ocdiag/__init__.py +1 -1
  17. package/ocdiag/__pycache__/__init__.cpython-310.pyc +0 -0
  18. package/ocdiag/__pycache__/cli.cpython-310.pyc +0 -0
  19. package/ocdiag/__pycache__/dispatcher.cpython-310.pyc +0 -0
  20. package/ocdiag/__pycache__/doctor.cpython-310.pyc +0 -0
  21. package/ocdiag/__pycache__/jsonlog.cpython-310.pyc +0 -0
  22. package/ocdiag/__pycache__/output.cpython-310.pyc +0 -0
  23. package/ocdiag/__pycache__/paths.cpython-310.pyc +0 -0
  24. package/ocdiag/__pycache__/recent_logs.cpython-310.pyc +0 -0
  25. package/ocdiag/__pycache__/sensitive.cpython-310.pyc +0 -0
  26. package/ocdiag/__pycache__/sessions.cpython-310.pyc +0 -0
  27. package/ocdiag/__pycache__/timeutil.cpython-310.pyc +0 -0
  28. package/ocdiag/__pycache__/tokens.cpython-310.pyc +0 -0
  29. package/ocdiag/cli.py +16 -1
  30. package/ocdiag/dispatcher.py +140 -53
  31. package/ocdiag/doctor.py +162 -0
  32. package/ocdiag/jsonlog.py +0 -5
  33. package/ocdiag/paths.py +0 -17
  34. package/ocdiag/recent_logs.py +0 -3
  35. package/ocdiag/sensitive.py +95 -1
  36. package/ocdiag/sessions.py +161 -0
  37. package/ocdiag/timeutil.py +0 -11
  38. package/ocdiag/tokens.py +0 -4
  39. package/package.json +2 -2
  40. package/tools/oc_session_extract.py +190 -67
  41. package/tools/oc_session_trace.py +48 -46
package/lib/bundle.py CHANGED
@@ -21,19 +21,12 @@ from typing import Dict, List, Optional
21
21
 
22
22
  REPO_ROOT = Path(__file__).resolve().parent.parent
23
23
 
24
- # ID script filename, kept in sync with ocdiag/dispatcher.py:MODULES.
25
- MODULES: List[tuple] = [
26
- ("sys_health", "diag/01_sys_health.py"),
27
- ("environment", "diag/02_environment.py"),
28
- ("configuration", "diag/03_configuration.py"),
29
- ("gateway", "diag/04_gateway.py"),
30
- ("recent_errors", "diag/05_recent_errors.py"),
31
- ("cron_jobs", "diag/06_cron_jobs.py"),
32
- ("performance", "diag/07_performance.py"),
33
- ("sessions", "diag/08_sessions.py"),
34
- ("plugin_diag", "diag/09_plugin_diag.py"),
35
- ("shell_history", "diag/10_shell_history.py"),
36
- ]
24
+ # Single source of truth: ocdiag.dispatcher. We import it dynamically so the
25
+ # bundle script stays runnable from a fresh checkout (no install needed).
26
+ sys.path.insert(0, str(REPO_ROOT))
27
+ from ocdiag.dispatcher import STATE_COLLECTORS # noqa: E402
28
+
29
+ MODULES: List[tuple] = [(mid, rel) for mid, _label, rel in STATE_COLLECTORS]
37
30
  MODULE_BY_ID = {mid: rel for mid, rel in MODULES}
38
31
 
39
32
  # Order matters: each submodule is exec'd into its own module object, and its
@@ -1,3 +1,3 @@
1
1
  """ocdiag — shared library for openclaw-diag-cli scripts."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.2"
package/ocdiag/cli.py CHANGED
@@ -1,14 +1,23 @@
1
- """Common argparse setup for diag scripts."""
1
+ """Common argparse setup for diag scripts.
2
+
3
+ When invoked via the dispatcher (`openclaw-diag <id>`), the dispatcher exports
4
+ OPENCLAW_DIAG_PROG="openclaw-diag <id>" before running the script so argparse
5
+ uses that as `prog`. When you run the script directly (e.g.
6
+ `python3 diag/01_sys_health.py`), argparse falls back to the script basename.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
11
  import argparse
12
+ import os
6
13
  from typing import Optional
7
14
 
8
15
  from . import paths
9
16
 
10
17
 
11
18
  def build_common_parser(description: str, prog: Optional[str] = None) -> argparse.ArgumentParser:
19
+ if prog is None:
20
+ prog = os.environ.get("OPENCLAW_DIAG_PROG") or None
12
21
  p = argparse.ArgumentParser(
13
22
  prog=prog,
14
23
  description=description,
@@ -36,4 +45,10 @@ def build_common_parser(description: str, prog: Optional[str] = None) -> argpars
36
45
  )
37
46
  p.add_argument("--json", action="store_true", help="Emit JSON output")
38
47
  p.add_argument("--no-color", action="store_true", help="Disable colored output")
48
+ p.add_argument(
49
+ "--unmask",
50
+ action="store_true",
51
+ help="Disable default sanitization of secrets in free-form text "
52
+ "(shell history / plugin errors / systemd / sessions). Off by default.",
53
+ )
39
54
  return p
@@ -5,39 +5,42 @@ Layout:
5
5
  ocdiag <object-inspector> ARG runs that inspector (e.g. `ocdiag trace UUID`)
6
6
  ocdiag all [--skip a,b] runs every state collector
7
7
  ocdiag list prints the catalogue grouped by parameter mode
8
- ocdiag run <id> [args...] legacy alias retained for 0.1.x users
8
+ ocdiag bundle <id> emits a self-contained single-file .py
9
+ ocdiag doctor environment health check
9
10
  """
10
11
 
11
12
  from __future__ import annotations
12
13
 
14
+ import json
13
15
  import os
14
16
  import runpy
15
17
  import sys
16
18
  import time
19
+ import traceback
17
20
  from pathlib import Path
18
- from typing import List
21
+ from typing import List, Optional, Tuple
19
22
 
20
23
 
21
24
  REPO_ROOT = Path(__file__).resolve().parent.parent
22
25
 
23
26
  # State collectors: zero required args, parameter-free observation of system state.
24
27
  STATE_COLLECTORS = [
25
- ("sys_health", "系统健康检查", "diag/01_sys_health.py"),
26
- ("environment", "OpenClaw 基础环境", "diag/02_environment.py"),
27
- ("configuration", "配置展平(脱敏)", "diag/03_configuration.py"),
28
- ("gateway", "Gateway 状态", "diag/04_gateway.py"),
29
- ("recent_errors", "近期错误聚合", "diag/05_recent_errors.py"),
30
- ("cron_jobs", "定时任务状态", "diag/06_cron_jobs.py"),
31
- ("performance", "模型/工具性能", "diag/07_performance.py"),
32
- ("sessions", "Session 数据", "diag/08_sessions.py"),
33
- ("plugin_diag", "插件诊断", "diag/09_plugin_diag.py"),
34
- ("shell_history", "Shell 历史", "diag/10_shell_history.py"),
28
+ ("sys_health", "系统健康(DNS / 网络 / CPU / 内存 / 磁盘 / 进程 / 时间)", "diag/01_sys_health.py"),
29
+ ("environment", "OpenClaw 版本、Gateway 进程环境变量", "diag/02_environment.py"),
30
+ ("configuration", "openclaw.json 展平(敏感字段脱敏)", "diag/03_configuration.py"),
31
+ ("gateway", "Gateway 进程、端口、24h 重启、WS 生命周期、错误码", "diag/04_gateway.py"),
32
+ ("recent_errors", "应用日志 / journalctl / session 工具调用错误聚合", "diag/05_recent_errors.py"),
33
+ ("cron_jobs", "定时任务状态、连续失败、调度漂移、静默检测", "diag/06_cron_jobs.py"),
34
+ ("performance", "模型/工具耗时 P50/P95、慢调用、E2E 延迟、Cache 命中率", "diag/07_performance.py"),
35
+ ("sessions", "Session 总览、活跃度、Stuck 探测", "diag/08_sessions.py"),
36
+ ("plugin_diag", "插件状态一致性、ERROR/WARN、Hook、Channel、外部 DNS", "diag/09_plugin_diag.py"),
37
+ ("shell_history", "Shell 历史中的高危命令与最近操作", "diag/10_shell_history.py"),
35
38
  ]
36
39
 
37
40
  # Object inspectors: take a session uuid (or other identifier) and inspect it.
38
41
  OBJECT_INSPECTORS = [
39
- ("trace", "追踪用户消息时间轴", "tools/oc_session_trace.py"),
40
- ("extract", "导出 session 为可读格式", "tools/oc_session_extract.py"),
42
+ ("trace", "追踪一条用户消息从进入到响应的完整时间轴", "tools/oc_session_trace.py"),
43
+ ("extract", "导出 session.jsonl 为可读格式", "tools/oc_session_extract.py"),
41
44
  ]
42
45
 
43
46
  STATE_BY_ID = {mid: (label, script) for mid, label, script in STATE_COLLECTORS}
@@ -46,31 +49,67 @@ MODULE_BY_ID = {**STATE_BY_ID, **OBJECT_BY_ID}
46
49
  MODULE_IDS = set(MODULE_BY_ID.keys())
47
50
 
48
51
 
52
+ def cmd_list_json() -> int:
53
+ """Machine-readable module catalogue. Single source of truth consumed
54
+ by the Node shell and the bundle script (axiom #3)."""
55
+ payload = {
56
+ "state_collectors": [
57
+ {"id": mid, "label": label, "script": rel}
58
+ for mid, label, rel in STATE_COLLECTORS
59
+ ],
60
+ "object_inspectors": [
61
+ {"id": mid, "label": label, "script": rel}
62
+ for mid, label, rel in OBJECT_INSPECTORS
63
+ ],
64
+ }
65
+ print(json.dumps(payload, ensure_ascii=False))
66
+ return 0
67
+
68
+
49
69
  def cmd_list() -> int:
50
- print("Available diagnostics:")
70
+ print("openclaw-diag — 可用诊断")
51
71
  print()
52
- print(" State collectors (no args needed):")
72
+ print(" 扫描类(无需参数):")
53
73
  for mid, label, _ in STATE_COLLECTORS:
54
74
  print(f" {mid:<16s} {label}")
55
75
  print()
56
- print(" Object inspectors (require session uuid):")
76
+ print(" 对象类(需要 session uuid):")
57
77
  for mid, label, _ in OBJECT_INSPECTORS:
58
78
  print(f" {mid:<16s} {label}")
59
79
  print()
60
- print(" Meta:")
61
- print(" all 跑全部 state collectors")
62
- print(" doctor 检查 Node/Python/OpenClaw 环境")
63
- print(" bundle <id> 打包成 self-contained 单文件")
80
+ print(" 其它命令:")
81
+ print(" all 一次跑完所有扫描类")
82
+ print(" doctor 检查 Node / Python / openclaw-diag / OpenClaw 环境")
83
+ print(" bundle <id> 生成 self-contained 单文件 .py(离线机器用)")
64
84
  return 0
65
85
 
66
86
 
67
- def run_script(script_rel: str, extra_args: List[str]) -> int:
87
+ def run_script(
88
+ script_rel: str,
89
+ extra_args: List[str],
90
+ module_id: Optional[str] = None,
91
+ ) -> int:
92
+ """Execute a diag script in-process. Returns the rc.
93
+
94
+ On crash, in addition to the human-readable stderr trace we emit a single
95
+ NDJSON error record to stdout when the script was invoked with --json.
96
+ This guarantees `all --json` produces N records for N modules — including
97
+ crashes — so downstream parsers don't silently lose modules. (Axiom #4)
98
+ """
68
99
  script_path = REPO_ROOT / script_rel
69
100
  if not script_path.is_file():
70
101
  print(f"Error: script not found: {script_path}", file=sys.stderr)
71
102
  return 2
103
+ json_mode = "--json" in extra_args
104
+ mid = module_id or script_path.stem
72
105
  saved_argv = sys.argv[:]
106
+ saved_prog = os.environ.get("OPENCLAW_DIAG_PROG")
73
107
  try:
108
+ # runpy.run_path resets sys.argv[0] to the script path, so we
109
+ # advertise the user-facing name through an env var instead. cli.py
110
+ # picks it up as the argparse prog so --help reads as
111
+ # "openclaw-diag sys_health" rather than "01_sys_health.py".
112
+ os.environ["OPENCLAW_DIAG_PROG"] = f"openclaw-diag {mid}"
74
113
  sys.argv = [str(script_path), *extra_args]
75
114
  runpy.run_path(str(script_path), run_name="__main__")
76
115
  return 0
@@ -79,14 +118,32 @@ def run_script(script_rel: str, extra_args: List[str]) -> int:
79
118
  return int(e.code) if e.code is not None else 0
80
119
  except (TypeError, ValueError):
81
120
  return 1
82
- except Exception as e:
121
+ except BaseException as e: # noqa: BLE001 — emit then re-classify
83
122
  print(f" ERROR: {script_path.name} crashed: {type(e).__name__}: {e}",
84
123
  file=sys.stderr)
85
- import traceback
86
124
  traceback.print_exc(file=sys.stderr)
125
+ if json_mode:
126
+ err_record = {
127
+ "module": mid,
128
+ "status": "error",
129
+ "error": f"{type(e).__name__}: {e}",
130
+ "traceback": traceback.format_exc(),
131
+ }
132
+ try:
133
+ sys.stdout.write(json.dumps(err_record, ensure_ascii=False) + "\n")
134
+ sys.stdout.flush()
135
+ except Exception:
136
+ # If stdout itself is broken (closed pipe), there's nothing
137
+ # productive to do — the stderr trace above already records
138
+ # the crash.
139
+ pass
87
140
  return 2
88
141
  finally:
89
142
  sys.argv = saved_argv
143
+ if saved_prog is None:
144
+ os.environ.pop("OPENCLAW_DIAG_PROG", None)
145
+ else:
146
+ os.environ["OPENCLAW_DIAG_PROG"] = saved_prog
90
147
 
91
148
 
92
149
  def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
@@ -101,7 +158,7 @@ def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
101
158
  n += 1
102
159
  print(f"\n[{n}/{total}] {label} ({mid})...", flush=True, file=progress_stream)
103
160
  t0 = time.time()
104
- rc = run_script(script, extra_args)
161
+ rc = run_script(script, extra_args, module_id=mid)
105
162
  elapsed = time.time() - t0
106
163
  print(f"[{n}/{total}] {label} ({mid}) ... done ({elapsed:.1f}s)",
107
164
  flush=True, file=progress_stream)
@@ -110,7 +167,27 @@ def cmd_all(extra_args: List[str], skip_ids: List[str]) -> int:
110
167
  return rc_overall
111
168
 
112
169
 
113
- def _split_skip(rest: List[str]) -> (List[str], List[str]):
170
+ def cmd_bundle(rest: List[str]) -> int:
171
+ """Generate a self-contained single-file diag script.
172
+
173
+ Lives here (rather than in lib/bundle.py only) so the Python entry has
174
+ parity with Node — `python3 bin/ocdiag bundle gateway` works the same as
175
+ `node bin/openclaw-diag.js bundle gateway`. (Axiom #3)
176
+ """
177
+ if not rest or rest[0] in ("-h", "--help"):
178
+ print("Usage: openclaw-diag bundle <id>", file=sys.stderr)
179
+ print(" Emits the bundle to stdout. Use shell redirection to save.", file=sys.stderr)
180
+ print(file=sys.stderr)
181
+ print("Available ids:", file=sys.stderr)
182
+ for mid, _label, _ in STATE_COLLECTORS:
183
+ print(f" {mid}", file=sys.stderr)
184
+ return 0 if rest else 2
185
+ sys.path.insert(0, str(REPO_ROOT / "lib"))
186
+ import bundle # type: ignore
187
+ return bundle.main(rest)
188
+
189
+
190
+ def _split_skip(rest: List[str]) -> Tuple[List[str], List[str]]:
114
191
  """Pull out --skip a,b out of an argv tail; return (skip_ids, passthrough)."""
115
192
  skip_ids: List[str] = []
116
193
  passthrough: List[str] = []
@@ -126,22 +203,30 @@ def _split_skip(rest: List[str]) -> (List[str], List[str]):
126
203
  return skip_ids, passthrough
127
204
 
128
205
 
206
+ def _suggest_command(unknown: str) -> Optional[str]:
207
+ """Best-effort typo suggestion for a misspelled command."""
208
+ import difflib
209
+ candidates = list(MODULE_BY_ID.keys()) + ["all", "list", "doctor", "bundle"]
210
+ matches = difflib.get_close_matches(unknown, candidates, n=1, cutoff=0.6)
211
+ return matches[0] if matches else None
212
+
213
+
129
214
  def print_help() -> None:
130
- print("ocdiag — OpenClaw 诊断工具箱")
215
+ print("openclaw-diag — OpenClaw 诊断工具箱")
131
216
  print()
132
- print("Usage:")
133
- print(" ocdiag <id> [args...] 跑单个诊断(state collector 或 object inspector)")
134
- print(" ocdiag all [--skip a,b] 跑全部 state collectors")
135
- print(" ocdiag list 列出所有诊断")
136
- print(" ocdiag run <id> [args...] 旧用法别名(0.1.x 兼容)")
217
+ print("用法:")
218
+ print(" openclaw-diag <id> [args...] 跑单个诊断")
219
+ print(" openclaw-diag all [--skip a,b] 跑全部 state collectors")
220
+ print(" openclaw-diag list 列出所有诊断")
221
+ print(" openclaw-diag doctor 检查环境")
222
+ print(" openclaw-diag bundle <id> 生成单文件 .py")
137
223
  print()
138
- print("State collectors:")
224
+ print("扫描类(无需参数):")
139
225
  print(" " + " ".join(mid for mid, _, _ in STATE_COLLECTORS))
140
- print("Object inspectors:")
226
+ print("对象类(需要 session uuid):")
141
227
  print(" " + " ".join(mid for mid, _, _ in OBJECT_INSPECTORS))
142
228
  print()
143
- print("--skip 后接逗号分隔 id 列表(仅对 all 有意义)。")
144
- print("其它参数(--config / --log-dir / --json / --no-color)原样传递给脚本。")
229
+ print("常用 flag:--json(结构化输出) --no-color(关掉颜色) --unmask(不脱敏)")
145
230
 
146
231
 
147
232
  def main(argv=None) -> int:
@@ -154,33 +239,35 @@ def main(argv=None) -> int:
154
239
  head, rest = argv[0], argv[1:]
155
240
 
156
241
  if head == "list":
242
+ if "--json" in rest:
243
+ return cmd_list_json()
157
244
  return cmd_list()
158
245
 
246
+ if head == "doctor":
247
+ from ocdiag import doctor
248
+ json_mode = "--json" in rest
249
+ node_version = None
250
+ for i, a in enumerate(rest):
251
+ if a == "--node-version" and i + 1 < len(rest):
252
+ node_version = rest[i + 1]
253
+ break
254
+ return doctor.run(json_mode=json_mode, node_version=node_version)
255
+
159
256
  if head == "all":
160
257
  skip_ids, passthrough = _split_skip(rest)
161
258
  return cmd_all(passthrough, skip_ids)
162
259
 
163
- # Backward-compat alias: `ocdiag run <id> [args...]` still works.
164
- if head == "run":
165
- if not rest:
166
- print("Error: run requires a target (module id or 'all').", file=sys.stderr)
167
- return 2
168
- target, sub = rest[0], rest[1:]
169
- if target == "all":
170
- skip_ids, passthrough = _split_skip(sub)
171
- return cmd_all(passthrough, skip_ids)
172
- if target in MODULE_BY_ID:
173
- _, script = MODULE_BY_ID[target]
174
- return run_script(script, sub)
175
- print(f"Error: unknown diagnostic '{target}'. Use `ocdiag list`.", file=sys.stderr)
176
- return 2
260
+ if head == "bundle":
261
+ return cmd_bundle(rest)
177
262
 
178
263
  if head in MODULE_BY_ID:
179
264
  _, script = MODULE_BY_ID[head]
180
- return run_script(script, rest)
265
+ return run_script(script, rest, module_id=head)
181
266
 
182
- print(f"Error: unknown command '{head}'. Use `ocdiag list` to see available diagnostics.",
183
- file=sys.stderr)
267
+ suggestion = _suggest_command(head)
268
+ hint = f"(你是不是想说 `{suggestion}`?)" if suggestion else ""
269
+ print(f"Error: 未知命令 '{head}'{hint}", file=sys.stderr)
270
+ print(f"运行 `openclaw-diag list` 查看全部诊断。", file=sys.stderr)
184
271
  return 2
185
272
 
186
273
 
@@ -0,0 +1,162 @@
1
+ """``ocdiag doctor`` — environment health-check.
2
+
3
+ Sole authoritative implementation. Both Node (`bin/openclaw-diag.js doctor`)
4
+ and Python (`bin/ocdiag doctor` / `python3 -m ocdiag.doctor`) entry points
5
+ call this function. The Node entry is now a thin spawn wrapper.
6
+
7
+ Checks:
8
+ - Python version (>= 3.8)
9
+ - ocdiag package importable + version
10
+ - All registered diag scripts respond to ``--help``
11
+ - openclaw.json exists at expected path
12
+
13
+ Node version isn't visible from Python so we accept it as a passthrough
14
+ argument; if absent, doctor reports node check as ``skipped``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parent.parent
28
+
29
+
30
+ def _node_status(node_version: Optional[str]) -> dict:
31
+ if not node_version:
32
+ return {"version": None, "ok": True, "skipped": True,
33
+ "reason": "Node check is performed by the Node entry; "
34
+ "ocdiag is fine without Node when invoked from Python"}
35
+ # Normalize: accept "v22.22.2" or "22.22.2"
36
+ normalized = node_version.lstrip("v")
37
+ try:
38
+ major = int(normalized.split(".", 1)[0])
39
+ except ValueError:
40
+ return {"version": normalized, "ok": False, "reason": "unparseable"}
41
+ return {"version": normalized, "ok": major >= 18,
42
+ "required": ">=18"}
43
+
44
+
45
+ def _python_status() -> dict:
46
+ v = sys.version_info
47
+ return {
48
+ "version": f"{v.major}.{v.minor}.{v.micro}",
49
+ "ok": v >= (3, 8),
50
+ "required": ">=3.8",
51
+ "executable": sys.executable,
52
+ }
53
+
54
+
55
+ def _ocdiag_status() -> dict:
56
+ try:
57
+ import ocdiag # type: ignore
58
+ return {"ok": True, "version": getattr(ocdiag, "__version__", "?")}
59
+ except ImportError as e:
60
+ return {"ok": False, "error": str(e)[:200]}
61
+
62
+
63
+ def _diag_scripts_status() -> dict:
64
+ from ocdiag.dispatcher import STATE_COLLECTORS, OBJECT_INSPECTORS
65
+ failed = []
66
+ all_scripts = []
67
+ for mid, _label, rel in (*STATE_COLLECTORS, *OBJECT_INSPECTORS):
68
+ all_scripts.append((mid, REPO_ROOT / rel))
69
+ for mid, path in all_scripts:
70
+ if not path.is_file():
71
+ failed.append({"script": mid, "reason": "missing", "path": str(path)})
72
+ continue
73
+ r = subprocess.run(
74
+ [sys.executable, str(path), "--help"],
75
+ capture_output=True, text=True, timeout=10, check=False,
76
+ )
77
+ if r.returncode != 0:
78
+ failed.append({
79
+ "script": mid,
80
+ "rc": r.returncode,
81
+ "stderr": (r.stderr or "")[:200],
82
+ })
83
+ return {"ok": not failed, "total": len(all_scripts), "failed": failed}
84
+
85
+
86
+ def _openclaw_config_status() -> dict:
87
+ home = os.path.expanduser("~")
88
+ cfg = os.environ.get("OPENCLAW_CONFIG") or os.path.join(
89
+ os.environ.get("OPENCLAW_HOME", os.path.join(home, ".openclaw")),
90
+ "openclaw.json",
91
+ )
92
+ return {"path": cfg, "exists": os.path.isfile(cfg)}
93
+
94
+
95
+ def run(json_mode: bool = False, node_version: Optional[str] = None) -> int:
96
+ """Execute the doctor check. Returns rc (0 if everything OK, 1 otherwise)."""
97
+ result = {
98
+ "node": _node_status(node_version),
99
+ "python": _python_status(),
100
+ "ocdiag": _ocdiag_status(),
101
+ "diag_scripts": _diag_scripts_status(),
102
+ "openclaw_config": _openclaw_config_status(),
103
+ }
104
+
105
+ ok = (
106
+ result["node"].get("ok", True)
107
+ and result["python"]["ok"]
108
+ and result["ocdiag"]["ok"]
109
+ and result["diag_scripts"]["ok"]
110
+ )
111
+
112
+ if json_mode:
113
+ print(json.dumps(result, ensure_ascii=False, indent=2))
114
+ else:
115
+ node = result["node"]
116
+ if node.get("skipped"):
117
+ print(f"ℹ Node check skipped (run via npx to verify Node version)")
118
+ elif node["ok"]:
119
+ print(f"✓ Node v{node['version']}")
120
+ else:
121
+ print(f"✗ Node v{node.get('version','?')} (need {node.get('required','?')})")
122
+
123
+ py = result["python"]
124
+ mark = "✓" if py["ok"] else "✗"
125
+ print(f"{mark} Python {py['version']} ({py['executable']})")
126
+
127
+ oc = result["ocdiag"]
128
+ if oc["ok"]:
129
+ print(f"✓ ocdiag package importable (version {oc['version']})")
130
+ else:
131
+ print(f"✗ ocdiag package not importable: {oc.get('error','?')}")
132
+
133
+ ds = result["diag_scripts"]
134
+ if ds["ok"]:
135
+ print(f"✓ All {ds['total']} diagnostics respond to --help")
136
+ else:
137
+ print(f"✗ {len(ds['failed'])}/{ds['total']} diagnostics failed --help:")
138
+ for f in ds["failed"]:
139
+ print(f" {f.get('script','?')} (rc={f.get('rc','?')})")
140
+
141
+ cfg = result["openclaw_config"]
142
+ if cfg["exists"]:
143
+ print(f"✓ OpenClaw config present ({cfg['path']})")
144
+ else:
145
+ print(f"ℹ OpenClaw config not found ({cfg['path']}) — diagnostics will run but report missing")
146
+
147
+ return 0 if ok else 1
148
+
149
+
150
+ def main(argv=None) -> int:
151
+ p = argparse.ArgumentParser(prog="ocdiag-doctor",
152
+ description="Health-check the ocdiag install + environment")
153
+ p.add_argument("--json", action="store_true", help="Emit JSON output")
154
+ p.add_argument("--node-version", default=None,
155
+ help="Node version string (e.g. '20.12.1') passed in by the Node "
156
+ "shell. Omit when running from Python directly.")
157
+ args = p.parse_args(argv)
158
+ return run(json_mode=args.json, node_version=args.node_version)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())
package/ocdiag/jsonlog.py CHANGED
@@ -58,8 +58,3 @@ def parse_name(obj: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
58
58
  return p.get("plugin"), p.get("subsystem")
59
59
 
60
60
 
61
- def log_level(obj: Dict[str, Any]) -> str:
62
- meta = obj.get("_meta") or {}
63
- if isinstance(meta, dict):
64
- return meta.get("logLevelName", "") or ""
65
- return ""
package/ocdiag/paths.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from pathlib import Path
7
6
 
8
7
 
9
8
  def _env_path(name: str, default: str) -> str:
@@ -30,19 +29,3 @@ SERVICE_ENV_FILE = _env_path(
30
29
  "OPENCLAW_SERVICE_ENV_FILE",
31
30
  os.path.join(HOME, ".config", "systemd", "user", "openclaw-gateway.service.d", "env.conf"),
32
31
  )
33
-
34
-
35
- def home() -> str:
36
- return HOME
37
-
38
-
39
- def config_path() -> str:
40
- return CONFIG
41
-
42
-
43
- def log_dir() -> str:
44
- return LOG_DIR
45
-
46
-
47
- def sessions_base() -> str:
48
- return SESSIONS_BASE
@@ -48,6 +48,3 @@ def latest_app_log(log_dir: str) -> Optional[str]:
48
48
  return matched[0][1]
49
49
 
50
50
 
51
- def all_logs(log_dir: str) -> List[str]:
52
- pattern = os.path.join(log_dir, "openclaw-*.log")
53
- return sorted(glob.glob(pattern))