@neurodock/cli 0.4.3 → 0.6.0
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.
- package/CHANGELOG.md +93 -0
- package/README.md +39 -12
- package/dist/assets/hooks/neurodock_daemon.py +506 -0
- package/dist/assets/hooks/proactive_guardrail.py +520 -0
- package/dist/commands/install-hooks.d.ts +20 -0
- package/dist/commands/install-hooks.d.ts.map +1 -0
- package/dist/commands/install-hooks.js +356 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/sync.d.ts +24 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +179 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.d.ts +13 -22
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +11 -176
- package/dist/commands/update.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -3
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
- package/LICENSE +0 -661
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
1
1
|
# @neurodock/cli changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Added — `neurodock install-hooks` (proactive guardrails Phases 1 + 3)
|
|
6
|
+
|
|
7
|
+
One command wires NeuroDock's proactive-guardrail layer into a fresh
|
|
8
|
+
install. Bundled self-contained Python scripts ship with the npm
|
|
9
|
+
package — no extra `pip install` step.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
neurodock install-hooks --self-test
|
|
13
|
+
neurodock install-hooks --install-daemon --self-test
|
|
14
|
+
neurodock install-hooks --uninstall
|
|
15
|
+
neurodock install-hooks --dry-run
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The command:
|
|
19
|
+
|
|
20
|
+
1. Copies `proactive_guardrail.py` to `~/.neurodock/hooks/` (Phase 1
|
|
21
|
+
Claude Code hook; auto-fires chronometric / rumination /
|
|
22
|
+
sycophancy checks on every Nth tool use, banners on stderr).
|
|
23
|
+
2. Copies `neurodock_daemon.py` to the same dir (Phase 3 host-agnostic
|
|
24
|
+
poller; same heuristics, native OS notifications).
|
|
25
|
+
3. Idempotently merges 4 entries into `~/.claude/settings.json`
|
|
26
|
+
(`SessionStart`, `PreToolUse`, `PostToolUse`, `Stop`), preserving
|
|
27
|
+
any existing hooks from other tools.
|
|
28
|
+
4. With `--install-daemon`, registers the daemon at user-login
|
|
29
|
+
autostart: HKCU Run on Windows, LaunchAgent on macOS, systemd
|
|
30
|
+
`--user` unit on Linux.
|
|
31
|
+
5. With `--self-test`, runs both scripts' built-in smoke test to
|
|
32
|
+
verify Python is on PATH and the heuristics fire.
|
|
33
|
+
|
|
34
|
+
**Opt-out:**
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
neurodock install-hooks --uninstall # removes hook + daemon entries
|
|
38
|
+
export NEURODOCK_GUARDRAILS=off # disables without removing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Fixed — Windows path-escape lockout (regression-pinned)
|
|
42
|
+
|
|
43
|
+
Pre-0.6.0 the hook command written into `settings.json` used
|
|
44
|
+
backslashes on Windows. Bash-style shells (Git Bash / MinGW — what
|
|
45
|
+
Claude Code uses for hooks on Windows) interpret `\U`, `\h`, `\p`
|
|
46
|
+
etc. as escape sequences and strip them, mangling the path to
|
|
47
|
+
`pythonscript.py` and causing every PreToolUse hook to fail, which
|
|
48
|
+
**blocks every tool call** until the user manually edits
|
|
49
|
+
`settings.json`. The 0.0.22 install command hit this. Twice.
|
|
50
|
+
|
|
51
|
+
0.6.0 always normalises the script path to forward slashes (Python
|
|
52
|
+
accepts them on Windows) and always wraps it in double quotes. The
|
|
53
|
+
new install summary also echoes the exact hook command string so the
|
|
54
|
+
bug class can't recur silently.
|
|
55
|
+
|
|
56
|
+
Regression test: `packages/cli/tests/install-hooks.test.ts` pins the
|
|
57
|
+
contract — the test fails if any hook entry contains a backslash in
|
|
58
|
+
its script-path portion.
|
|
59
|
+
|
|
60
|
+
### Internals
|
|
61
|
+
|
|
62
|
+
- New build step `scripts/copy-assets.mjs` copies `src/assets/` into
|
|
63
|
+
`dist/assets/` after `tsc`, so the published npm tarball contains
|
|
64
|
+
the bundled Python scripts.
|
|
65
|
+
- `package.json` `files` array already includes `dist/`, so no
|
|
66
|
+
manifest change needed.
|
|
67
|
+
|
|
68
|
+
## 0.5.0
|
|
69
|
+
|
|
70
|
+
### Changed (breaking)
|
|
71
|
+
|
|
72
|
+
- `neurodock update` now upgrades NeuroDock to the latest version
|
|
73
|
+
(re-installs the six MCP servers via `pip install --upgrade` or
|
|
74
|
+
`uv tool install` and re-wires clients). Previously, `update` only
|
|
75
|
+
re-shaped client config JSON without touching package versions —
|
|
76
|
+
which confused users who expected `update` to behave like every
|
|
77
|
+
other CLI's `update` verb.
|
|
78
|
+
- The previous behavior moved to a new verb: `neurodock sync`. Same
|
|
79
|
+
flags (`--client`, `--dry-run`), same code path, same semantics.
|
|
80
|
+
- The new `update` command accepts the same flags as `install-all`
|
|
81
|
+
(`--client`, `--profile`, `--installer`, `--skip-install`, `--yes`,
|
|
82
|
+
`--dry-run`, `--no-native-host`).
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- README now has a dedicated `## Update` section documenting the
|
|
87
|
+
one-liner: `npx --yes @neurodock/cli update`.
|
|
88
|
+
|
|
89
|
+
### Migration
|
|
90
|
+
|
|
91
|
+
If you scripted against `neurodock update --client X --dry-run` to
|
|
92
|
+
reconcile client configs, rename the call to `neurodock sync` — the
|
|
93
|
+
old flags still work there. If you wanted package upgrades, the new
|
|
94
|
+
`neurodock update` is what you were looking for.
|
|
95
|
+
|
|
3
96
|
## 0.4.3
|
|
4
97
|
|
|
5
98
|
### Changed
|
package/README.md
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
The `neurodock` installer and diagnostic CLI for [NeuroDock](https://neurodock.org/) — a local-first cognitive substrate for neurodivergent professionals.
|
|
4
4
|
|
|
5
|
-
Status: **v0.
|
|
5
|
+
Status: **v0.6.0**.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# From a published package:
|
|
11
|
-
npx --yes @neurodock/cli install-all
|
|
11
|
+
npx --yes @neurodock/cli@latest install-all
|
|
12
12
|
|
|
13
13
|
# Or step-by-step, also from npm:
|
|
14
|
-
npx --yes @neurodock/cli init
|
|
14
|
+
npx --yes @neurodock/cli@latest init
|
|
15
15
|
|
|
16
16
|
# From a fresh clone of the monorepo:
|
|
17
17
|
pnpm install
|
|
@@ -38,7 +38,8 @@ install, `install-all` runs the `pip install` step automatically.
|
|
|
38
38
|
| `neurodock init` | Install MCP servers into Claude Desktop / Claude Code / Cursor (the wiring half of `install-all`). |
|
|
39
39
|
| `neurodock doctor` | Diagnose your install — profile validity, client wiring, tool availability. |
|
|
40
40
|
| `neurodock validate` | Schema-validate a profile file (`~/.neurodock/profile.yaml` by default). |
|
|
41
|
-
| `neurodock update` |
|
|
41
|
+
| `neurodock update` | Upgrade NeuroDock to the latest version — re-installs the six MCP servers via pip/uv and re-wires client configs. |
|
|
42
|
+
| `neurodock sync` | Re-shape stale NeuroDock MCP entries in existing client configs. No package upgrade. Non-NeuroDock entries are preserved. |
|
|
42
43
|
| `neurodock uninstall` | Reverse `init` — remove NeuroDock MCP entries from each client config. Optionally purge `~/.neurodock/`. |
|
|
43
44
|
| `neurodock examples` | Print a copy-pasteable prompt cheat-sheet that exercises every wired NeuroDock MCP tool. |
|
|
44
45
|
| `neurodock host install` | Register the optional Chrome Native Messaging host so the browser extension can read `~/.neurodock/profile.yaml` directly. |
|
|
@@ -92,21 +93,47 @@ What `init` does, in order:
|
|
|
92
93
|
Idempotent. Re-running with no changes is a no-op. Collisions on a previous
|
|
93
94
|
key are skipped unless `--yes` is supplied.
|
|
94
95
|
|
|
95
|
-
### `neurodock
|
|
96
|
+
### `neurodock update`
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
neurodock update [--client=claude-desktop|claude-code|cursor|all] \
|
|
100
|
+
[--profile=minimal|example] \
|
|
101
|
+
[--installer=uv|pip|auto] \
|
|
102
|
+
[--skip-install] [--yes] [--dry-run] [--no-native-host]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
One-command upgrade. Same code path as `install-all` — re-runs
|
|
106
|
+
`pip install --upgrade` (or `uv tool install`) for every NeuroDock MCP
|
|
107
|
+
server, re-wires the detected MCP clients, and re-registers the
|
|
108
|
+
optional native-messaging host. Exit codes match `install-all`:
|
|
109
|
+
`0` ok, `1` an entrypoint is missing from PATH, `2` init failed.
|
|
110
|
+
|
|
111
|
+
### `neurodock sync`
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
neurodock sync [--client <id>] [--dry-run]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Re-shape stale NeuroDock MCP entries in existing client configs
|
|
118
|
+
(version drift, command/args/cwd changes) without upgrading any
|
|
119
|
+
packages. Non-NeuroDock entries round-trip untouched. Useful when the
|
|
120
|
+
desired wiring shape changed but you don't need a package upgrade.
|
|
121
|
+
|
|
122
|
+
Before 0.5.0 this lived under `neurodock update`. The verb moved
|
|
123
|
+
because users typed `neurodock update` expecting a version upgrade.
|
|
124
|
+
|
|
125
|
+
### `neurodock validate` / `uninstall`
|
|
96
126
|
|
|
97
127
|
```
|
|
98
128
|
neurodock validate [--file <path>] [--strict]
|
|
99
|
-
neurodock update [--client <id>] [--dry-run]
|
|
100
129
|
neurodock uninstall [--client <id>] [--yes] [--purge] [--dry-run]
|
|
101
130
|
```
|
|
102
131
|
|
|
103
132
|
`validate` runs Ajv against `profile.schema.json` and reports field-path
|
|
104
|
-
violations. `
|
|
105
|
-
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
`~/.neurodock/cognitive-graph.sqlite` (default: no). `--purge` deletes
|
|
109
|
-
those without prompting.
|
|
133
|
+
violations. `uninstall` removes NeuroDock entries from every detected
|
|
134
|
+
client config; asks (interactively) whether to delete
|
|
135
|
+
`~/.neurodock/profile.yaml` and `~/.neurodock/cognitive-graph.sqlite`
|
|
136
|
+
(default: no). `--purge` deletes those without prompting.
|
|
110
137
|
|
|
111
138
|
### `neurodock examples`
|
|
112
139
|
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""NeuroDock proactive-guardrail daemon (Phase 3).
|
|
3
|
+
|
|
4
|
+
A long-running poller that watches the same state files the Phase 1
|
|
5
|
+
Claude Code hook updates (`~/.neurodock/state/guardrail-session.json`
|
|
6
|
+
and `~/.neurodock/state/guardrail-prompts.json`), evaluates the same
|
|
7
|
+
hyperfocus / rumination heuristics, and surfaces interventions via the
|
|
8
|
+
host OS's native notification channel.
|
|
9
|
+
|
|
10
|
+
Why a separate daemon when the Phase 1 hook already exists:
|
|
11
|
+
- The hook only fires inside Claude Code. It can't catch you working
|
|
12
|
+
in a terminal at 02:00, doomscrolling at 02:30, or back in
|
|
13
|
+
Claude Code at 03:00 with a stale `started_at`.
|
|
14
|
+
- The daemon is host-agnostic: it polls on a wall-clock tick, runs
|
|
15
|
+
the same heuristics, and notifies you whether you're typing in
|
|
16
|
+
your IDE, in chat, or doing nothing at all.
|
|
17
|
+
|
|
18
|
+
Install (one-time, bundled with `@neurodock/cli`):
|
|
19
|
+
|
|
20
|
+
neurodock install-hooks --install-daemon
|
|
21
|
+
|
|
22
|
+
…which calls into this script's `install` subcommand to set up an
|
|
23
|
+
auto-start entry (Windows: HKCU Run key; macOS: LaunchAgent plist;
|
|
24
|
+
Linux: ~/.config/systemd/user/neurodock-guardrail.service).
|
|
25
|
+
|
|
26
|
+
Run manually:
|
|
27
|
+
|
|
28
|
+
python ~/.neurodock/hooks/neurodock_daemon.py run
|
|
29
|
+
python ~/.neurodock/hooks/neurodock_daemon.py install
|
|
30
|
+
python ~/.neurodock/hooks/neurodock_daemon.py uninstall
|
|
31
|
+
python ~/.neurodock/hooks/neurodock_daemon.py self-test
|
|
32
|
+
|
|
33
|
+
Opt-out at any time:
|
|
34
|
+
|
|
35
|
+
export NEURODOCK_GUARDRAILS=off # disables daemon AND hook
|
|
36
|
+
python ~/.neurodock/hooks/neurodock_daemon.py uninstall
|
|
37
|
+
|
|
38
|
+
Pure stdlib. No pip install. Works on Python 3.11+.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import platform
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import time
|
|
49
|
+
from datetime import datetime, timedelta, timezone
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
VERSION = "0.0.1"
|
|
54
|
+
STATE_DIR = Path.home() / ".neurodock" / "state"
|
|
55
|
+
HOOK_DIR = Path.home() / ".neurodock" / "hooks"
|
|
56
|
+
SESSION_FILE = STATE_DIR / "guardrail-session.json"
|
|
57
|
+
PROMPTS_FILE = STATE_DIR / "guardrail-prompts.json"
|
|
58
|
+
DAEMON_STATE_FILE = STATE_DIR / "daemon.json"
|
|
59
|
+
LOG_FILE = STATE_DIR / "daemon-log.jsonl"
|
|
60
|
+
|
|
61
|
+
# Tick every 5 minutes by default. Tunable via env for testing.
|
|
62
|
+
TICK_SECONDS = int(os.environ.get("NEURODOCK_DAEMON_TICK_SECONDS", "300"))
|
|
63
|
+
|
|
64
|
+
# Dedup window — don't re-fire the same signal within this many seconds.
|
|
65
|
+
DEDUP_SECONDS = 30 * 60
|
|
66
|
+
|
|
67
|
+
# Heuristic thresholds — mirror the Phase 1 hook so the user gets a
|
|
68
|
+
# consistent experience whether they're in Claude Code or not.
|
|
69
|
+
HYPERFOCUS_BREAK_MINUTES = 90
|
|
70
|
+
DEEP_NIGHT_HOURS = range(0, 6) # 00:00..05:59 local
|
|
71
|
+
LATE_NIGHT_HOURS = range(22, 24) # 22:00..23:59 local
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
if os.environ.get("NEURODOCK_GUARDRAILS", "").lower() == "off":
|
|
76
|
+
return 0
|
|
77
|
+
if len(sys.argv) < 2:
|
|
78
|
+
sys.stderr.write(_usage())
|
|
79
|
+
return 1
|
|
80
|
+
cmd = sys.argv[1]
|
|
81
|
+
if cmd == "run":
|
|
82
|
+
return _run_forever()
|
|
83
|
+
if cmd == "tick":
|
|
84
|
+
return _run_one_tick()
|
|
85
|
+
if cmd == "install":
|
|
86
|
+
return _install_autostart()
|
|
87
|
+
if cmd == "uninstall":
|
|
88
|
+
return _uninstall_autostart()
|
|
89
|
+
if cmd == "self-test":
|
|
90
|
+
return _self_test()
|
|
91
|
+
sys.stderr.write(_usage())
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _usage() -> str:
|
|
96
|
+
return (
|
|
97
|
+
f"NeuroDock proactive-guardrail daemon v{VERSION}\n"
|
|
98
|
+
" run poll forever (foreground)\n"
|
|
99
|
+
" tick run one evaluation cycle and exit\n"
|
|
100
|
+
" install register autostart for the current user\n"
|
|
101
|
+
" uninstall remove autostart entry\n"
|
|
102
|
+
" self-test smoke-test the heuristics\n"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Run loop ─────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run_forever() -> int:
|
|
110
|
+
"""Block forever, ticking every TICK_SECONDS. Exits silently on SIGINT."""
|
|
111
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
_log("daemon-start", {"tick_seconds": TICK_SECONDS, "version": VERSION})
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
_run_one_tick()
|
|
116
|
+
time.sleep(TICK_SECONDS)
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
_log("daemon-stop", {"reason": "sigint"})
|
|
119
|
+
return 0
|
|
120
|
+
except Exception as exc: # noqa: BLE001 — must not crash autostart
|
|
121
|
+
_log("daemon-error", {"error": str(exc)})
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_one_tick() -> int:
|
|
126
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
state = _load_daemon_state()
|
|
128
|
+
now = _now()
|
|
129
|
+
signal = _evaluate(now)
|
|
130
|
+
if signal is None:
|
|
131
|
+
return 0
|
|
132
|
+
# Dedup: don't re-fire the same signal kind within DEDUP_SECONDS.
|
|
133
|
+
last = state.get("last_surfaced", {})
|
|
134
|
+
last_kind = last.get("kind")
|
|
135
|
+
last_at = last.get("at")
|
|
136
|
+
if (
|
|
137
|
+
last_kind == signal["kind"]
|
|
138
|
+
and isinstance(last_at, str)
|
|
139
|
+
):
|
|
140
|
+
try:
|
|
141
|
+
elapsed = (now - datetime.fromisoformat(last_at)).total_seconds()
|
|
142
|
+
if elapsed < DEDUP_SECONDS:
|
|
143
|
+
return 0
|
|
144
|
+
except ValueError:
|
|
145
|
+
pass
|
|
146
|
+
_surface(signal)
|
|
147
|
+
state["last_surfaced"] = {
|
|
148
|
+
"kind": signal["kind"],
|
|
149
|
+
"at": now.isoformat(),
|
|
150
|
+
}
|
|
151
|
+
_save_daemon_state(state)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── Heuristic evaluation ─────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _evaluate(now: datetime) -> dict[str, Any] | None:
|
|
159
|
+
"""Return a signal dict, or None when nothing trips this tick."""
|
|
160
|
+
session = _load_session()
|
|
161
|
+
started_iso = session.get("started_at") if isinstance(session, dict) else None
|
|
162
|
+
|
|
163
|
+
# Deep-night check first — most universally applicable signal.
|
|
164
|
+
hour = now.hour
|
|
165
|
+
band: str | None = None
|
|
166
|
+
if hour in DEEP_NIGHT_HOURS:
|
|
167
|
+
band = "deep_night"
|
|
168
|
+
elif hour in LATE_NIGHT_HOURS:
|
|
169
|
+
band = "late_night"
|
|
170
|
+
if band:
|
|
171
|
+
return {
|
|
172
|
+
"kind": "clock_band",
|
|
173
|
+
"band": band,
|
|
174
|
+
"hour": hour,
|
|
175
|
+
"title": "NeuroDock — late-night check",
|
|
176
|
+
"message": (
|
|
177
|
+
f"It's {band.replace('_', ' ')} local ({hour:02d}:00). "
|
|
178
|
+
"If you can save and stop, that's the kindest move."
|
|
179
|
+
),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Hyperfocus on an open Claude Code session.
|
|
183
|
+
if isinstance(started_iso, str):
|
|
184
|
+
try:
|
|
185
|
+
started = datetime.fromisoformat(started_iso)
|
|
186
|
+
except ValueError:
|
|
187
|
+
started = None
|
|
188
|
+
if started is not None:
|
|
189
|
+
elapsed_min = (now - started).total_seconds() / 60.0
|
|
190
|
+
if elapsed_min >= HYPERFOCUS_BREAK_MINUTES:
|
|
191
|
+
return {
|
|
192
|
+
"kind": "hyperfocus",
|
|
193
|
+
"elapsed_min": int(elapsed_min),
|
|
194
|
+
"title": "NeuroDock — hyperfocus check",
|
|
195
|
+
"message": (
|
|
196
|
+
f"This Claude Code session has been open "
|
|
197
|
+
f"{int(elapsed_min)} min. Worth a real break — "
|
|
198
|
+
"walk, hydrate, switch context for 10 min."
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── Native notification ──────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _surface(signal: dict[str, Any]) -> None:
|
|
209
|
+
title = signal.get("title", "NeuroDock")
|
|
210
|
+
message = signal.get("message", "")
|
|
211
|
+
_log("surface", {"kind": signal.get("kind"), "title": title})
|
|
212
|
+
system = platform.system()
|
|
213
|
+
try:
|
|
214
|
+
if system == "Windows":
|
|
215
|
+
_notify_windows(title, message)
|
|
216
|
+
elif system == "Darwin":
|
|
217
|
+
_notify_macos(title, message)
|
|
218
|
+
else:
|
|
219
|
+
_notify_linux(title, message)
|
|
220
|
+
except Exception as exc: # noqa: BLE001 — never crash on a notify failure
|
|
221
|
+
_log("notify-error", {"error": str(exc)})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _notify_windows(title: str, message: str) -> None:
|
|
225
|
+
# Use PowerShell BurntToast-free approach: New-BurntToastNotification
|
|
226
|
+
# isn't always present. Use the built-in toast API via XML through
|
|
227
|
+
# the Windows Runtime instead. Falls back to msg.exe on failure.
|
|
228
|
+
ps_script = (
|
|
229
|
+
'[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime] | Out-Null;'
|
|
230
|
+
'$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent('
|
|
231
|
+
'[Windows.UI.Notifications.ToastTemplateType]::ToastText02);'
|
|
232
|
+
f'$template.GetElementsByTagName("text").Item(0).AppendChild($template.CreateTextNode("{_escape(title)}")) | Out-Null;'
|
|
233
|
+
f'$template.GetElementsByTagName("text").Item(1).AppendChild($template.CreateTextNode("{_escape(message)}")) | Out-Null;'
|
|
234
|
+
'$notify = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("NeuroDock");'
|
|
235
|
+
'$notify.Show([Windows.UI.Notifications.ToastNotification]::new($template));'
|
|
236
|
+
)
|
|
237
|
+
subprocess.run(
|
|
238
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
239
|
+
check=False,
|
|
240
|
+
capture_output=True,
|
|
241
|
+
timeout=10,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _notify_macos(title: str, message: str) -> None:
|
|
246
|
+
script = (
|
|
247
|
+
f'display notification "{_escape(message)}" with title "{_escape(title)}"'
|
|
248
|
+
)
|
|
249
|
+
subprocess.run(
|
|
250
|
+
["osascript", "-e", script],
|
|
251
|
+
check=False,
|
|
252
|
+
capture_output=True,
|
|
253
|
+
timeout=10,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _notify_linux(title: str, message: str) -> None:
|
|
258
|
+
# Most desktop environments have notify-send. We don't fail loud if
|
|
259
|
+
# it's absent — Linux server users without a display server will
|
|
260
|
+
# see the daemon log entries but no toast.
|
|
261
|
+
subprocess.run(
|
|
262
|
+
["notify-send", "-a", "NeuroDock", title, message],
|
|
263
|
+
check=False,
|
|
264
|
+
capture_output=True,
|
|
265
|
+
timeout=10,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _escape(s: str) -> str:
|
|
270
|
+
return s.replace('"', '\\"').replace("\n", " ").replace("\r", " ")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ── Autostart wiring ─────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _install_autostart() -> int:
|
|
277
|
+
"""Register a per-user autostart entry on the current OS."""
|
|
278
|
+
system = platform.system()
|
|
279
|
+
daemon_script = (HOOK_DIR / "neurodock_daemon.py").resolve()
|
|
280
|
+
if not daemon_script.exists():
|
|
281
|
+
sys.stderr.write(
|
|
282
|
+
f"daemon script not found at {daemon_script} — run "
|
|
283
|
+
"`neurodock install-hooks` first.\n"
|
|
284
|
+
)
|
|
285
|
+
return 1
|
|
286
|
+
if system == "Windows":
|
|
287
|
+
return _install_windows_autostart(daemon_script)
|
|
288
|
+
if system == "Darwin":
|
|
289
|
+
return _install_macos_launchagent(daemon_script)
|
|
290
|
+
return _install_linux_systemd_user_unit(daemon_script)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _uninstall_autostart() -> int:
|
|
294
|
+
system = platform.system()
|
|
295
|
+
if system == "Windows":
|
|
296
|
+
return _uninstall_windows_autostart()
|
|
297
|
+
if system == "Darwin":
|
|
298
|
+
return _uninstall_macos_launchagent()
|
|
299
|
+
return _uninstall_linux_systemd_user_unit()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _install_windows_autostart(daemon_script: Path) -> int:
|
|
303
|
+
# Register an HKCU Run entry so the daemon launches at user logon.
|
|
304
|
+
# No admin rights required.
|
|
305
|
+
cmd = (
|
|
306
|
+
f'python "{str(daemon_script).replace(chr(92), "/")}" run'
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
subprocess.run(
|
|
310
|
+
[
|
|
311
|
+
"reg", "add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
|
312
|
+
"/v", "NeuroDockGuardrail",
|
|
313
|
+
"/t", "REG_SZ",
|
|
314
|
+
"/d", cmd,
|
|
315
|
+
"/f",
|
|
316
|
+
],
|
|
317
|
+
check=True,
|
|
318
|
+
capture_output=True,
|
|
319
|
+
timeout=10,
|
|
320
|
+
)
|
|
321
|
+
sys.stdout.write(
|
|
322
|
+
"Registered HKCU Run entry. Daemon will launch at next logon.\n"
|
|
323
|
+
)
|
|
324
|
+
return 0
|
|
325
|
+
except subprocess.CalledProcessError as exc:
|
|
326
|
+
sys.stderr.write(f"reg add failed: {exc.stderr.decode(errors='ignore')}\n")
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _uninstall_windows_autostart() -> int:
|
|
331
|
+
try:
|
|
332
|
+
subprocess.run(
|
|
333
|
+
[
|
|
334
|
+
"reg", "delete", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
|
335
|
+
"/v", "NeuroDockGuardrail",
|
|
336
|
+
"/f",
|
|
337
|
+
],
|
|
338
|
+
check=True,
|
|
339
|
+
capture_output=True,
|
|
340
|
+
timeout=10,
|
|
341
|
+
)
|
|
342
|
+
return 0
|
|
343
|
+
except subprocess.CalledProcessError:
|
|
344
|
+
# Already absent — fine.
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _install_macos_launchagent(daemon_script: Path) -> int:
|
|
349
|
+
agent_dir = Path.home() / "Library" / "LaunchAgents"
|
|
350
|
+
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
351
|
+
plist = agent_dir / "org.neurodock.guardrail.plist"
|
|
352
|
+
contents = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
353
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
354
|
+
<plist version="1.0">
|
|
355
|
+
<dict>
|
|
356
|
+
<key>Label</key><string>org.neurodock.guardrail</string>
|
|
357
|
+
<key>ProgramArguments</key>
|
|
358
|
+
<array>
|
|
359
|
+
<string>/usr/bin/env</string>
|
|
360
|
+
<string>python3</string>
|
|
361
|
+
<string>{daemon_script}</string>
|
|
362
|
+
<string>run</string>
|
|
363
|
+
</array>
|
|
364
|
+
<key>RunAtLoad</key><true/>
|
|
365
|
+
<key>KeepAlive</key><true/>
|
|
366
|
+
<key>StandardOutPath</key><string>{LOG_FILE}</string>
|
|
367
|
+
<key>StandardErrorPath</key><string>{LOG_FILE}</string>
|
|
368
|
+
</dict>
|
|
369
|
+
</plist>
|
|
370
|
+
"""
|
|
371
|
+
plist.write_text(contents, encoding="utf-8")
|
|
372
|
+
subprocess.run(["launchctl", "load", str(plist)], check=False, capture_output=True)
|
|
373
|
+
sys.stdout.write(f"Wrote LaunchAgent at {plist}\n")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _uninstall_macos_launchagent() -> int:
|
|
378
|
+
plist = Path.home() / "Library" / "LaunchAgents" / "org.neurodock.guardrail.plist"
|
|
379
|
+
if not plist.exists():
|
|
380
|
+
return 0
|
|
381
|
+
subprocess.run(["launchctl", "unload", str(plist)], check=False, capture_output=True)
|
|
382
|
+
plist.unlink(missing_ok=True)
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _install_linux_systemd_user_unit(daemon_script: Path) -> int:
|
|
387
|
+
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
|
388
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
unit = unit_dir / "neurodock-guardrail.service"
|
|
390
|
+
contents = f"""[Unit]
|
|
391
|
+
Description=NeuroDock proactive guardrail daemon
|
|
392
|
+
|
|
393
|
+
[Service]
|
|
394
|
+
ExecStart=/usr/bin/env python3 {daemon_script} run
|
|
395
|
+
Restart=always
|
|
396
|
+
RestartSec=10
|
|
397
|
+
|
|
398
|
+
[Install]
|
|
399
|
+
WantedBy=default.target
|
|
400
|
+
"""
|
|
401
|
+
unit.write_text(contents, encoding="utf-8")
|
|
402
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
|
|
403
|
+
subprocess.run(
|
|
404
|
+
["systemctl", "--user", "enable", "--now", "neurodock-guardrail.service"],
|
|
405
|
+
check=False,
|
|
406
|
+
capture_output=True,
|
|
407
|
+
)
|
|
408
|
+
sys.stdout.write(f"Wrote systemd user unit at {unit}\n")
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _uninstall_linux_systemd_user_unit() -> int:
|
|
413
|
+
unit = (
|
|
414
|
+
Path.home() / ".config" / "systemd" / "user" / "neurodock-guardrail.service"
|
|
415
|
+
)
|
|
416
|
+
subprocess.run(
|
|
417
|
+
["systemctl", "--user", "disable", "--now", "neurodock-guardrail.service"],
|
|
418
|
+
check=False,
|
|
419
|
+
capture_output=True,
|
|
420
|
+
)
|
|
421
|
+
if unit.exists():
|
|
422
|
+
unit.unlink(missing_ok=True)
|
|
423
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False, capture_output=True)
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ── State + logging ──────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _load_session() -> dict[str, Any]:
|
|
431
|
+
try:
|
|
432
|
+
with SESSION_FILE.open("r", encoding="utf-8") as fh:
|
|
433
|
+
data = json.load(fh)
|
|
434
|
+
return data if isinstance(data, dict) else {}
|
|
435
|
+
except (OSError, json.JSONDecodeError):
|
|
436
|
+
return {}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _load_daemon_state() -> dict[str, Any]:
|
|
440
|
+
try:
|
|
441
|
+
with DAEMON_STATE_FILE.open("r", encoding="utf-8") as fh:
|
|
442
|
+
data = json.load(fh)
|
|
443
|
+
return data if isinstance(data, dict) else {}
|
|
444
|
+
except (OSError, json.JSONDecodeError):
|
|
445
|
+
return {}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _save_daemon_state(state: dict[str, Any]) -> None:
|
|
449
|
+
try:
|
|
450
|
+
with DAEMON_STATE_FILE.open("w", encoding="utf-8") as fh:
|
|
451
|
+
json.dump(state, fh)
|
|
452
|
+
except OSError:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _log(event: str, data: dict[str, Any]) -> None:
|
|
457
|
+
try:
|
|
458
|
+
entry = {
|
|
459
|
+
"at": _now().isoformat(),
|
|
460
|
+
"event": event,
|
|
461
|
+
"version": VERSION,
|
|
462
|
+
**data,
|
|
463
|
+
}
|
|
464
|
+
with LOG_FILE.open("a", encoding="utf-8") as fh:
|
|
465
|
+
fh.write(json.dumps(entry) + "\n")
|
|
466
|
+
except OSError:
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _now() -> datetime:
|
|
471
|
+
return datetime.now(timezone.utc).astimezone()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ── Self-test ────────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _self_test() -> int:
|
|
478
|
+
ok = True
|
|
479
|
+
# Deep-night: synthesize a fake now at 02:00.
|
|
480
|
+
now = _now().replace(hour=2)
|
|
481
|
+
signal = _evaluate(now)
|
|
482
|
+
if signal is None or signal.get("kind") != "clock_band":
|
|
483
|
+
sys.stderr.write(f"FAIL: deep-night should fire at 02:00, got {signal}\n")
|
|
484
|
+
ok = False
|
|
485
|
+
# Hyperfocus: write a fake session 200 min ago, evaluate at midday.
|
|
486
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
fake_start = (now.replace(hour=12) - timedelta(minutes=200)).isoformat()
|
|
488
|
+
SESSION_FILE.write_text(
|
|
489
|
+
json.dumps({"started_at": fake_start, "tool_count": 5}),
|
|
490
|
+
encoding="utf-8",
|
|
491
|
+
)
|
|
492
|
+
midday = _now().replace(hour=12)
|
|
493
|
+
signal = _evaluate(midday)
|
|
494
|
+
if signal is None or signal.get("kind") != "hyperfocus":
|
|
495
|
+
sys.stderr.write(f"FAIL: hyperfocus should fire after 200 min, got {signal}\n")
|
|
496
|
+
ok = False
|
|
497
|
+
# Cleanup
|
|
498
|
+
SESSION_FILE.unlink(missing_ok=True)
|
|
499
|
+
if ok:
|
|
500
|
+
sys.stdout.write(f"OK: neurodock_daemon v{VERSION} self-test passed.\n")
|
|
501
|
+
return 0
|
|
502
|
+
return 1
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
if __name__ == "__main__":
|
|
506
|
+
sys.exit(main())
|