claude-wake 0.1.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/lib/doctor.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { loadConfig, projectsDir } from "./config.js";
4
+ import { getEditor } from "./editors.js";
5
+ import { listTranscripts } from "./detect.js";
6
+ import { checkSendPermissions } from "./send/index.js";
7
+ import { daemonStatus, readManifest } from "./install.js";
8
+
9
+ const OK = " ✔";
10
+ const BAD = " ✘";
11
+ const WARN = " ⚠";
12
+
13
+ export async function runDoctor() {
14
+ const cfg = loadConfig({ warn: (m) => console.log(`${WARN} ${m}`) });
15
+ const editor = getEditor(cfg.editor);
16
+ let failures = 0;
17
+
18
+ console.log("claude-wake doctor\n");
19
+
20
+ // Node
21
+ const major = parseInt(process.versions.node.split(".")[0], 10);
22
+ if (major >= 18) console.log(`${OK} node ${process.versions.node}`);
23
+ else {
24
+ console.log(`${BAD} node ${process.versions.node} — need >= 18`);
25
+ failures++;
26
+ }
27
+
28
+ // Transcript root
29
+ const root = projectsDir(cfg);
30
+ const transcripts = listTranscripts(root);
31
+ if (transcripts !== null) {
32
+ console.log(
33
+ `${OK} transcripts: ${root} (${transcripts.length} session files)`,
34
+ );
35
+ if (transcripts.length === 0)
36
+ console.log(
37
+ `${WARN} no session files yet — open a Claude Code panel once to create one`,
38
+ );
39
+ } else {
40
+ console.log(`${BAD} transcript dir not readable: ${root}`);
41
+ console.log(
42
+ " is Claude Code installed and used at least once on this machine?",
43
+ );
44
+ failures++;
45
+ }
46
+
47
+ // Editor / URI target
48
+ console.log(`${OK} editor preset: ${editor.name} (${editor.scheme}://)`);
49
+ if (process.platform === "darwin") {
50
+ const r = spawnSync("/usr/bin/open", ["-Ra", editor.appName], {
51
+ stdio: "ignore",
52
+ });
53
+ if (r.status === 0) console.log(`${OK} ${editor.appName} is installed`);
54
+ else {
55
+ console.log(
56
+ `${BAD} ${editor.appName} not found (config "editor" wrong?)`,
57
+ );
58
+ failures++;
59
+ }
60
+ }
61
+
62
+ // Send layer / permissions
63
+ try {
64
+ const perm = await checkSendPermissions(editor);
65
+ if (perm.ok) console.log(`${OK} send layer ready (${perm.detail})`);
66
+ else {
67
+ console.log(`${BAD} send layer: ${perm.detail}`);
68
+ failures++;
69
+ if (process.platform === "darwin") {
70
+ const { openPermissionUi, APP_DIR } = await import("./send/darwin.js");
71
+ console.log("\n macOS Accessibility permission needed (one-time):");
72
+ console.log(
73
+ " 1. System Settings → Privacy & Security → Accessibility",
74
+ );
75
+ console.log(` 2. Add and enable: ${APP_DIR}`);
76
+ console.log(
77
+ " (note: changing the `editor` config rebuilds KeySender.app — re-grant after that)",
78
+ );
79
+ console.log(" (opening both for you now …)");
80
+ openPermissionUi();
81
+ } else if (process.platform === "linux") {
82
+ console.log(" install hints: apt/dnf install xdotool xclip (X11)");
83
+ console.log(
84
+ " apt/dnf install wl-clipboard wtype (Wayland, experimental + needs allowUnverifiedPaste)",
85
+ );
86
+ }
87
+ }
88
+ } catch (err) {
89
+ console.log(`${BAD} send layer error: ${err.message}`);
90
+ failures++;
91
+ }
92
+
93
+ // Daemon + recorded launch paths still valid?
94
+ const d = daemonStatus();
95
+ if (d.installed) {
96
+ console.log(
97
+ `${d.running ? OK : WARN} daemon installed, ${d.running ? "running" : "NOT running"}`,
98
+ );
99
+ const manifest = readManifest();
100
+ if (manifest) {
101
+ for (const key of ["node", "cliBin"]) {
102
+ const p = manifest[key];
103
+ if (p && !fs.existsSync(p)) {
104
+ console.log(`${BAD} daemon launch path is gone: ${p}`);
105
+ console.log(
106
+ " (Node was upgraded/moved?) fix: claude-wake install (re-registers with current paths)",
107
+ );
108
+ failures++;
109
+ }
110
+ }
111
+ }
112
+ } else {
113
+ console.log(
114
+ `${WARN} daemon not installed (optional) — \`claude-wake install\` for always-on`,
115
+ );
116
+ }
117
+
118
+ if (process.platform === "linux") {
119
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
120
+ console.log(
121
+ `${WARN} no DISPLAY/WAYLAND_DISPLAY in this shell — sends need a GUI session`,
122
+ );
123
+ }
124
+ console.log(
125
+ `${WARN} systemd note: if the daemon can't reach your display, run: systemctl --user import-environment DISPLAY WAYLAND_DISPLAY XAUTHORITY`,
126
+ );
127
+ }
128
+
129
+ console.log(
130
+ failures === 0
131
+ ? "\nAll checks passed. Try it end-to-end: claude-wake test"
132
+ : `\n${failures} check(s) failed — fix the ✘ items above, then re-run: claude-wake doctor`,
133
+ );
134
+ return failures === 0;
135
+ }
package/lib/editors.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Editor presets. `scheme` is the URI protocol the editor registers;
3
+ * the extension id part of the URI is always anthropic.claude-code.
4
+ *
5
+ * - appName: macOS application name (for `activate`)
6
+ * - bundleIds: macOS bundle identifiers accepted as the frontmost app before
7
+ * any keystroke (names like "Electron" are ambiguous across apps — bundle
8
+ * ids are not)
9
+ * - windowTitle: suffix matched against the active window title (Linux X11 /
10
+ * Windows); VS Code-family titles end with the product name by default
11
+ */
12
+ const PRESETS = {
13
+ vscode: {
14
+ scheme: "vscode",
15
+ appName: "Visual Studio Code",
16
+ bundleIds: ["com.microsoft.VSCode"],
17
+ windowTitle: "Visual Studio Code",
18
+ },
19
+ "vscode-insiders": {
20
+ scheme: "vscode-insiders",
21
+ appName: "Visual Studio Code - Insiders",
22
+ bundleIds: ["com.microsoft.VSCodeInsiders"],
23
+ windowTitle: "Visual Studio Code - Insiders",
24
+ },
25
+ cursor: {
26
+ scheme: "cursor",
27
+ appName: "Cursor",
28
+ bundleIds: ["com.todesktop.230313mzl4w4u92"],
29
+ windowTitle: "Cursor",
30
+ },
31
+ windsurf: {
32
+ scheme: "windsurf",
33
+ appName: "Windsurf",
34
+ bundleIds: ["com.exafunction.windsurf"],
35
+ windowTitle: "Windsurf",
36
+ },
37
+ };
38
+
39
+ const SESSION_ID_RE =
40
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
41
+
42
+ export function isValidSessionId(id) {
43
+ return typeof id === "string" && SESSION_ID_RE.test(id);
44
+ }
45
+
46
+ // Schemes that must never be used as the "open" protocol — file:// can become
47
+ // a UNC/SMB path on Windows (NTLM-hash leak), and the script/data family are
48
+ // code-execution surfaces. Editor URL schemes are none of these.
49
+ const BLOCKED_SCHEMES = new Set([
50
+ "file",
51
+ "javascript",
52
+ "vbscript",
53
+ "data",
54
+ "about",
55
+ "blob",
56
+ "http",
57
+ "https",
58
+ "smb",
59
+ "\\\\",
60
+ ]);
61
+
62
+ export function getEditor(name) {
63
+ const preset = PRESETS[name];
64
+ if (preset) return { name, ...preset };
65
+ // Unknown name: treat it as a raw URI scheme (power users / new forks), but
66
+ // only if it looks like an editor scheme and is not a dangerous protocol.
67
+ // Frontmost verification then falls back to the vscode identity — if the
68
+ // fork's bundle id differs, sends fail closed rather than typing blind.
69
+ if (
70
+ typeof name === "string" &&
71
+ /^[a-z][a-z0-9+.-]{0,31}$/.test(name) &&
72
+ !BLOCKED_SCHEMES.has(name)
73
+ ) {
74
+ return { name, ...PRESETS.vscode, scheme: name };
75
+ }
76
+ return { name: "vscode", ...PRESETS.vscode };
77
+ }
78
+
79
+ /** Build the panel-focus URI. Throws on invalid session id — never interpolate unvalidated input. */
80
+ export function buildFocusUri(editor, sessionId) {
81
+ if (!isValidSessionId(sessionId)) {
82
+ throw new Error(`invalid session id: ${String(sessionId).slice(0, 64)}`);
83
+ }
84
+ return `${editor.scheme}://anthropic.claude-code/open?session=${encodeURIComponent(sessionId)}`;
85
+ }
86
+
87
+ export const EDITOR_NAMES = Object.keys(PRESETS);
package/lib/install.js ADDED
@@ -0,0 +1,315 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { BASE_DIR, HOME, LOG_FILE, ensureBaseDir } from "./config.js";
5
+
6
+ /**
7
+ * Autostart registration — a thin per-OS wrapper around `claude-wake watch`.
8
+ * The core never depends on the daemon; `watch` in a terminal is always enough.
9
+ *
10
+ * Path-stability (hostile review): `process.execPath` resolves symlinks to a
11
+ * VERSION-PINNED binary (Homebrew Cellar, nvm versions dir) that dies on the
12
+ * next `brew upgrade node` / `nvm install` — leaving launchd retrying a
13
+ * nonexistent path forever. We prefer the `node` living next to the invoked
14
+ * CLI bin (npm installs them side by side behind stable symlinks), record
15
+ * both paths in an install manifest for `doctor` to re-check, and warn
16
+ * loudly when the chosen paths look version-pinned.
17
+ */
18
+
19
+ const LABEL = "com.claude-wake.watch";
20
+ const MANIFEST = path.join(BASE_DIR, "install.json");
21
+
22
+ const VERSIONED_PATH_RE = /\/Cellar\/node|[\\/]\.nvm[\\/]versions[\\/]|node@\d/;
23
+
24
+ /** Prefer the stable `node` symlink sitting next to the CLI bin. */
25
+ function stableNodePath(cliBin) {
26
+ const sibling = path.join(
27
+ path.dirname(cliBin),
28
+ process.platform === "win32" ? "node.exe" : "node",
29
+ );
30
+ try {
31
+ if (fs.existsSync(sibling)) return sibling;
32
+ } catch {
33
+ /* fall through */
34
+ }
35
+ return process.execPath;
36
+ }
37
+
38
+ /**
39
+ * The CLI entry to launch. `process.argv[1]` is the path the user actually
40
+ * invoked (the npm global bin shim), which is stable across upgrades —
41
+ * unlike import.meta.url, which is the realpath.
42
+ */
43
+ export function resolveLaunchPaths(argv1) {
44
+ const cliBin = path.resolve(argv1);
45
+ const node = stableNodePath(cliBin);
46
+ const warnings = [];
47
+ for (const p of [node, cliBin]) {
48
+ if (VERSIONED_PATH_RE.test(p)) {
49
+ warnings.push(
50
+ `${p} looks version-pinned — after upgrading Node (nvm/brew), re-run \`claude-wake install\``,
51
+ );
52
+ }
53
+ }
54
+ return { node, cliBin, warnings };
55
+ }
56
+
57
+ function writeManifest(info) {
58
+ ensureBaseDir();
59
+ fs.writeFileSync(MANIFEST, JSON.stringify(info, null, 2), { mode: 0o600 });
60
+ }
61
+
62
+ export function readManifest() {
63
+ try {
64
+ return JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function xmlEscape(s) {
71
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
72
+ }
73
+
74
+ /* ---------------- macOS: launchd LaunchAgent ---------------- */
75
+
76
+ function launchAgentPath() {
77
+ return path.join(HOME, "Library", "LaunchAgents", `${LABEL}.plist`);
78
+ }
79
+
80
+ function installDarwin(node, cliBin) {
81
+ // KeepAlive on SuccessfulExit=false: restart crashes, but let a clean exit
82
+ // stop the job. KeepAlive=true + "duplicate lock → exit" respawn-flapped
83
+ // every 10s for as long as a manual watch ran (hostile review).
84
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
85
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
86
+ <plist version="1.0">
87
+ <dict>
88
+ <key>Label</key><string>${LABEL}</string>
89
+ <key>ProgramArguments</key>
90
+ <array>
91
+ <string>${xmlEscape(node)}</string>
92
+ <string>${xmlEscape(cliBin)}</string>
93
+ <string>watch</string>
94
+ <string>--quiet</string>
95
+ </array>
96
+ <key>RunAtLoad</key><true/>
97
+ <key>KeepAlive</key>
98
+ <dict>
99
+ <key>SuccessfulExit</key><false/>
100
+ </dict>
101
+ <key>ProcessType</key><string>Background</string>
102
+ <key>StandardOutPath</key><string>${xmlEscape(LOG_FILE)}.stdout</string>
103
+ <key>StandardErrorPath</key><string>${xmlEscape(LOG_FILE)}.stderr</string>
104
+ </dict>
105
+ </plist>
106
+ `;
107
+ const dest = launchAgentPath();
108
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
109
+ fs.writeFileSync(dest, plist);
110
+ const uid = process.getuid();
111
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], {
112
+ stdio: "ignore",
113
+ });
114
+ const r = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, dest], {
115
+ encoding: "utf8",
116
+ });
117
+ if (r.status !== 0)
118
+ throw new Error(`launchctl bootstrap failed: ${r.stderr || r.stdout}`);
119
+ return dest;
120
+ }
121
+
122
+ function uninstallDarwin() {
123
+ const uid = process.getuid();
124
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], {
125
+ stdio: "ignore",
126
+ });
127
+ fs.rmSync(launchAgentPath(), { force: true });
128
+ }
129
+
130
+ function statusDarwin() {
131
+ const uid = process.getuid();
132
+ const r = spawnSync("launchctl", ["print", `gui/${uid}/${LABEL}`], {
133
+ encoding: "utf8",
134
+ });
135
+ if (r.status !== 0)
136
+ return { installed: fs.existsSync(launchAgentPath()), running: false };
137
+ return { installed: true, running: /state = running/.test(r.stdout) };
138
+ }
139
+
140
+ /* ---------------- Linux: systemd --user ---------------- */
141
+
142
+ function systemdUnitPath() {
143
+ const cfgHome = process.env.XDG_CONFIG_HOME || path.join(HOME, ".config");
144
+ return path.join(cfgHome, "systemd", "user", "claude-wake.service");
145
+ }
146
+
147
+ function installLinux(node, cliBin) {
148
+ // Restart=on-failure + duplicate-lock exiting 0 = no respawn flap.
149
+ // Paths are quoted (systemd syntax) in case $HOME contains spaces.
150
+ const unit = `[Unit]
151
+ Description=claude-wake: auto-resume Claude Code panels after usage-limit resets
152
+ After=graphical-session.target
153
+ PartOf=graphical-session.target
154
+
155
+ [Service]
156
+ ExecStart="${node}" "${cliBin}" watch --quiet
157
+ Restart=on-failure
158
+ RestartSec=10
159
+
160
+ [Install]
161
+ WantedBy=graphical-session.target
162
+ `;
163
+ const dest = systemdUnitPath();
164
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
165
+ fs.writeFileSync(dest, unit);
166
+ let r = spawnSync("systemctl", ["--user", "daemon-reload"], {
167
+ encoding: "utf8",
168
+ });
169
+ if (r.status !== 0)
170
+ throw new Error(`systemctl daemon-reload failed: ${r.stderr}`);
171
+ r = spawnSync(
172
+ "systemctl",
173
+ ["--user", "enable", "--now", "claude-wake.service"],
174
+ {
175
+ encoding: "utf8",
176
+ },
177
+ );
178
+ if (r.status !== 0) throw new Error(`systemctl enable failed: ${r.stderr}`);
179
+ return dest;
180
+ }
181
+
182
+ function uninstallLinux() {
183
+ spawnSync(
184
+ "systemctl",
185
+ ["--user", "disable", "--now", "claude-wake.service"],
186
+ {
187
+ stdio: "ignore",
188
+ },
189
+ );
190
+ fs.rmSync(systemdUnitPath(), { force: true });
191
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
192
+ }
193
+
194
+ function statusLinux() {
195
+ const r = spawnSync(
196
+ "systemctl",
197
+ ["--user", "is-active", "claude-wake.service"],
198
+ {
199
+ encoding: "utf8",
200
+ },
201
+ );
202
+ return {
203
+ installed: fs.existsSync(systemdUnitPath()),
204
+ running: (r.stdout || "").trim() === "active",
205
+ };
206
+ }
207
+
208
+ /* ---------------- Windows: Startup-folder VBS (hidden) ---------------- */
209
+
210
+ function startupVbsPath() {
211
+ return path.join(
212
+ process.env.APPDATA || path.join(HOME, "AppData", "Roaming"),
213
+ "Microsoft",
214
+ "Windows",
215
+ "Start Menu",
216
+ "Programs",
217
+ "Startup",
218
+ "claude-wake.vbs",
219
+ );
220
+ }
221
+
222
+ function installWin32(node, cliBin) {
223
+ // VBS string literals escape quotes by doubling them.
224
+ const q = (s) => `""${s.replace(/"/g, '""')}""`;
225
+ const cmd = `${q(node)} ${q(cliBin)} watch --quiet`;
226
+ const vbs = `' claude-wake autostart (hidden window)\r\nCreateObject("WScript.Shell").Run "${cmd}", 0, False\r\n`;
227
+ const dest = startupVbsPath();
228
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
229
+ fs.writeFileSync(dest, vbs);
230
+ spawnSync("wscript.exe", [dest], { stdio: "ignore" }); // start now, hidden
231
+ return dest;
232
+ }
233
+
234
+ function uninstallWin32() {
235
+ fs.rmSync(startupVbsPath(), { force: true });
236
+ // Best effort: stop a running watcher via its lock pid.
237
+ try {
238
+ const pid = parseInt(
239
+ fs.readFileSync(path.join(BASE_DIR, "watch.lock"), "utf8"),
240
+ 10,
241
+ );
242
+ if (Number.isFinite(pid)) process.kill(pid);
243
+ } catch {
244
+ /* not running */
245
+ }
246
+ }
247
+
248
+ function statusWin32() {
249
+ let running = false;
250
+ try {
251
+ const lock = path.join(BASE_DIR, "watch.lock");
252
+ const pid = parseInt(fs.readFileSync(lock, "utf8"), 10);
253
+ const fresh = Date.now() - fs.statSync(lock).mtimeMs < 3 * 60_000;
254
+ if (Number.isFinite(pid) && fresh) {
255
+ process.kill(pid, 0);
256
+ running = true;
257
+ }
258
+ } catch {
259
+ /* not running */
260
+ }
261
+ return { installed: fs.existsSync(startupVbsPath()), running };
262
+ }
263
+
264
+ /* ---------------- dispatch ---------------- */
265
+
266
+ export function installDaemon(argv1) {
267
+ ensureBaseDir();
268
+ const { node, cliBin, warnings } = resolveLaunchPaths(argv1);
269
+ let dest;
270
+ switch (process.platform) {
271
+ case "darwin":
272
+ dest = installDarwin(node, cliBin);
273
+ break;
274
+ case "linux":
275
+ dest = installLinux(node, cliBin);
276
+ break;
277
+ case "win32":
278
+ dest = installWin32(node, cliBin);
279
+ break;
280
+ default:
281
+ throw new Error(`unsupported platform: ${process.platform}`);
282
+ }
283
+ writeManifest({ node, cliBin, dest, installedAt: new Date().toISOString() });
284
+ return { dest, warnings };
285
+ }
286
+
287
+ export function uninstallDaemon() {
288
+ switch (process.platform) {
289
+ case "darwin":
290
+ uninstallDarwin();
291
+ break;
292
+ case "linux":
293
+ uninstallLinux();
294
+ break;
295
+ case "win32":
296
+ uninstallWin32();
297
+ break;
298
+ default:
299
+ throw new Error(`unsupported platform: ${process.platform}`);
300
+ }
301
+ fs.rmSync(MANIFEST, { force: true });
302
+ }
303
+
304
+ export function daemonStatus() {
305
+ switch (process.platform) {
306
+ case "darwin":
307
+ return statusDarwin();
308
+ case "linux":
309
+ return statusLinux();
310
+ case "win32":
311
+ return statusWin32();
312
+ default:
313
+ return { installed: false, running: false };
314
+ }
315
+ }
package/lib/log.js ADDED
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import { LOG_FILE, ensureBaseDir } from "./config.js";
3
+
4
+ const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate at 5 MB, keep the last 1 MB
5
+ const KEEP_BYTES = 1024 * 1024;
6
+
7
+ function stamp() {
8
+ const d = new Date();
9
+ const p = (n, w = 2) => String(n).padStart(w, "0");
10
+ return (
11
+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
12
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
13
+ );
14
+ }
15
+
16
+ function rotateIfNeeded() {
17
+ try {
18
+ const { size } = fs.statSync(LOG_FILE);
19
+ if (size <= MAX_LOG_BYTES) return;
20
+ const fd = fs.openSync(LOG_FILE, "r");
21
+ const buf = Buffer.alloc(KEEP_BYTES);
22
+ fs.readSync(fd, buf, 0, KEEP_BYTES, size - KEEP_BYTES);
23
+ fs.closeSync(fd);
24
+ fs.writeFileSync(LOG_FILE, buf);
25
+ } catch {
26
+ /* rotation is best-effort */
27
+ }
28
+ }
29
+
30
+ /** Logger that mirrors to stdout (foreground) and appends to the log file. */
31
+ export function createLogger({ quiet = false } = {}) {
32
+ ensureBaseDir();
33
+ return function log(msg) {
34
+ const line = `[${stamp()}] ${msg}`;
35
+ if (!quiet) console.log(line);
36
+ try {
37
+ rotateIfNeeded();
38
+ fs.appendFileSync(LOG_FILE, line + "\n");
39
+ } catch {
40
+ /* logging must never crash the watcher */
41
+ }
42
+ };
43
+ }