@shumin13/claude-pet 0.1.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.
- package/README.md +274 -0
- package/bin/claude-pet.js +85 -0
- package/hooks/claude-pet-clear.js +17 -0
- package/hooks/claude-pet-notify.js +26 -0
- package/hooks/claude-pet-stop.js +24 -0
- package/lib/config.js +19 -0
- package/lib/lock.js +35 -0
- package/lib/overlay-binary.js +104 -0
- package/lib/runtime.js +49 -0
- package/lib/session-labels.js +86 -0
- package/macos/RobotPetOverlay.swift +101 -0
- package/package.json +36 -0
- package/prebuilt/macos/robot-pet-overlay +0 -0
- package/public/app.js +516 -0
- package/public/desktop.css +719 -0
- package/public/index.html +103 -0
- package/public/styles.css +34 -0
- package/scripts/close-desktop-if-last-session.js +73 -0
- package/scripts/install-claude-hook.js +78 -0
- package/scripts/launch-desktop-if-needed.js +77 -0
- package/scripts/launch-desktop-if-needed.sh +7 -0
- package/scripts/run-desktop.sh +7 -0
- package/scripts/setup.js +198 -0
- package/server.js +139 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Claude Pet</title>
|
|
7
|
+
<link rel="stylesheet" href="styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main class="stage" aria-live="polite">
|
|
11
|
+
<section class="pet-shell" data-state="ready">
|
|
12
|
+
<div class="utility-controls" aria-label="Pet controls">
|
|
13
|
+
<button class="collapse-button" type="button" title="Collapse notification" aria-label="Collapse notification">−</button>
|
|
14
|
+
<button class="close-button" type="button" title="Close pet" aria-label="Close pet">×</button>
|
|
15
|
+
</div>
|
|
16
|
+
<button class="collapsed-badge" id="collapsedBadge" type="button" aria-label="Show notifications">0</button>
|
|
17
|
+
<div class="speech" id="speech">
|
|
18
|
+
<p class="eyebrow" id="eventType">Ready</p>
|
|
19
|
+
<h1 id="eventTitle">Claude Pet is awake</h1>
|
|
20
|
+
<p id="eventMessage">Waiting for Claude Code notifications.</p>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="project-tray" id="projectTray" aria-label="Queued projects"></div>
|
|
23
|
+
<div class="pet-wrap" aria-label="Cute robot notification pet">
|
|
24
|
+
<svg class="pet" viewBox="0 0 180 180" role="img" aria-labelledby="petTitle">
|
|
25
|
+
<title id="petTitle">Claude Pet, a cute rounded robot</title>
|
|
26
|
+
<defs>
|
|
27
|
+
<linearGradient id="botBody" x1="38" x2="137" y1="44" y2="145" gradientUnits="userSpaceOnUse">
|
|
28
|
+
<stop offset="0" stop-color="#f8fbff"/>
|
|
29
|
+
<stop offset="0.52" stop-color="#d8e7ef"/>
|
|
30
|
+
<stop offset="1" stop-color="#8faab7"/>
|
|
31
|
+
</linearGradient>
|
|
32
|
+
<linearGradient id="botGlass" x1="52" x2="128" y1="60" y2="114" gradientUnits="userSpaceOnUse">
|
|
33
|
+
<stop offset="0" stop-color="#25353a"/>
|
|
34
|
+
<stop offset="1" stop-color="#0f1719"/>
|
|
35
|
+
</linearGradient>
|
|
36
|
+
<linearGradient id="antennaGlow" x1="0" x2="1">
|
|
37
|
+
<stop offset="0" stop-color="#9df7bf"/>
|
|
38
|
+
<stop offset="1" stop-color="#77d9ff"/>
|
|
39
|
+
</linearGradient>
|
|
40
|
+
<filter id="robotShadow" x="-30%" y="-30%" width="160%" height="170%">
|
|
41
|
+
<feDropShadow dx="0" dy="12" stdDeviation="8" flood-color="#000" flood-opacity="0.32"/>
|
|
42
|
+
</filter>
|
|
43
|
+
</defs>
|
|
44
|
+
|
|
45
|
+
<ellipse class="shadow" cx="90" cy="156" rx="46" ry="10"/>
|
|
46
|
+
<g class="robot">
|
|
47
|
+
<g class="antenna">
|
|
48
|
+
<path d="M90 54 L90 23"/>
|
|
49
|
+
<circle cx="90" cy="19" r="9"/>
|
|
50
|
+
</g>
|
|
51
|
+
<g class="robot-body" filter="url(#robotShadow)">
|
|
52
|
+
<g class="arm left-arm">
|
|
53
|
+
<path class="side-hand" d="M40 103 C28 104 21 112 22 122 C22 128 27 131 33 128 C32 120 34 113 42 110Z"/>
|
|
54
|
+
</g>
|
|
55
|
+
<g class="arm right-arm">
|
|
56
|
+
<path class="side-hand" d="M140 103 C152 104 159 112 158 122 C158 128 153 131 147 128 C148 120 146 113 138 110Z"/>
|
|
57
|
+
</g>
|
|
58
|
+
<rect class="head" x="39" y="48" width="102" height="94" rx="31"/>
|
|
59
|
+
<rect class="face" x="53" y="65" width="74" height="48" rx="21"/>
|
|
60
|
+
<circle class="eye left-eye" cx="75" cy="89" r="6"/>
|
|
61
|
+
<circle class="eye right-eye" cx="105" cy="89" r="6"/>
|
|
62
|
+
<path class="happy-eye left-happy-eye" d="M69 88 C72 94 78 94 81 88"/>
|
|
63
|
+
<path class="happy-eye right-happy-eye" d="M99 88 C102 94 108 94 111 88"/>
|
|
64
|
+
<path class="brow left-brow" d="M67 78 L82 82"/>
|
|
65
|
+
<path class="brow right-brow" d="M98 82 L113 78"/>
|
|
66
|
+
<path class="mouth" d="M77 105 C84 111 96 111 103 105"/>
|
|
67
|
+
<path class="success-mouth" d="M79 103 C84 111 96 111 101 103"/>
|
|
68
|
+
<path class="permission-mouth" d="M84 106 C87 104 91 104 94 106"/>
|
|
69
|
+
<path class="idle-mouth" d="M82 106 C87 104 93 104 98 106"/>
|
|
70
|
+
<circle class="cheek left-cheek" cx="57" cy="110" r="8"/>
|
|
71
|
+
<circle class="cheek right-cheek" cx="123" cy="110" r="8"/>
|
|
72
|
+
<rect class="panel" x="75" y="129" width="30" height="7" rx="3.5"/>
|
|
73
|
+
<g class="feet">
|
|
74
|
+
<rect x="58" y="140" width="23" height="12" rx="6"/>
|
|
75
|
+
<rect x="99" y="140" width="23" height="12" rx="6"/>
|
|
76
|
+
</g>
|
|
77
|
+
</g>
|
|
78
|
+
</g>
|
|
79
|
+
<g class="alert-mark">
|
|
80
|
+
<circle cx="136" cy="54" r="18"/>
|
|
81
|
+
<path d="M136 43 L136 55"/>
|
|
82
|
+
<circle cx="136" cy="63" r="2.8"/>
|
|
83
|
+
</g>
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
<button class="resize-handle" type="button" title="Drag to resize" aria-label="Drag to resize">
|
|
87
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
88
|
+
<path d="M7 17L17 7"/>
|
|
89
|
+
<path d="M13 7h4v4"/>
|
|
90
|
+
<path d="M7 13v4h4"/>
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
<div class="controls">
|
|
94
|
+
<button type="button" data-demo="ready" title="Preview ready state">Ready</button>
|
|
95
|
+
<button type="button" data-demo="permission_prompt" title="Preview permission state">Permission</button>
|
|
96
|
+
<button type="button" data-demo="idle_prompt" title="Preview idle state">Idle</button>
|
|
97
|
+
<button type="button" data-demo="auth_success" title="Preview success state">Success</button>
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
</main>
|
|
101
|
+
<script src="app.js"></script>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
@import "desktop.css";
|
|
2
|
+
|
|
3
|
+
html[data-mode="desktop"] .controls,
|
|
4
|
+
html[data-mode="preview"] .utility-controls,
|
|
5
|
+
html[data-mode="preview"] .collapsed-badge,
|
|
6
|
+
html[data-mode="preview"] .resize-handle,
|
|
7
|
+
html[data-mode="preview"] .project-tray {
|
|
8
|
+
display: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.controls {
|
|
12
|
+
position: absolute;
|
|
13
|
+
left: 50%;
|
|
14
|
+
bottom: 0;
|
|
15
|
+
display: flex;
|
|
16
|
+
gap: 6px;
|
|
17
|
+
transform: translateX(-50%);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.controls button {
|
|
21
|
+
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
22
|
+
border-radius: 999px;
|
|
23
|
+
color: rgba(244, 244, 236, 0.84);
|
|
24
|
+
font: inherit;
|
|
25
|
+
font-size: 11px;
|
|
26
|
+
line-height: 1;
|
|
27
|
+
background: rgba(18, 20, 18, 0.72);
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
padding: 6px 8px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.controls button:hover {
|
|
33
|
+
background: rgba(31, 48, 52, 0.84);
|
|
34
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { hasSessionIdentity, pruneStaleSessions, readSessions, sessionKey, sessionsFile, writeSessions } from "../lib/session-labels.js";
|
|
5
|
+
import { eventsUrl, lifecycleLockDir, logDir, overlayPidFile, serverPidFile } from "../lib/config.js";
|
|
6
|
+
import { withFileLock } from "../lib/lock.js";
|
|
7
|
+
import { isAlive, postJson, readPid, readStdinJson, removeFile } from "../lib/runtime.js";
|
|
8
|
+
|
|
9
|
+
async function killPidFile(path) {
|
|
10
|
+
const pid = await readPid(path);
|
|
11
|
+
if (isAlive(pid)) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(-pid, "SIGTERM");
|
|
14
|
+
} catch {
|
|
15
|
+
// Fall back to killing only the recorded process.
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, "SIGTERM");
|
|
19
|
+
} catch {
|
|
20
|
+
// Ignore cleanup races.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await removeFile(path);
|
|
25
|
+
} catch {
|
|
26
|
+
// Ignore cleanup races.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function resetPetServer() {
|
|
31
|
+
try {
|
|
32
|
+
await postJson(eventsUrl, {
|
|
33
|
+
type: "ready",
|
|
34
|
+
title: "Claude Pet is awake",
|
|
35
|
+
message: "Waiting for Claude Code notifications.",
|
|
36
|
+
replay: true
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
// Server may already be closed.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const event = await readStdinJson();
|
|
45
|
+
await mkdir(logDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
await withFileLock(lifecycleLockDir, async () => {
|
|
48
|
+
const sessionId = hasSessionIdentity(event) ? sessionKey(event) : undefined;
|
|
49
|
+
const sessions = pruneStaleSessions(await readSessions());
|
|
50
|
+
if (sessionId) delete sessions[sessionId];
|
|
51
|
+
|
|
52
|
+
const remaining = Object.keys(sessions);
|
|
53
|
+
if (remaining.length > 0) {
|
|
54
|
+
await writeSessions(sessions);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await removeFile(sessionsFile);
|
|
60
|
+
} catch {
|
|
61
|
+
// Already gone.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await resetPetServer();
|
|
65
|
+
await killPidFile(overlayPidFile);
|
|
66
|
+
await killPidFile(serverPidFile);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main().catch(error => {
|
|
71
|
+
console.error(error?.message || error);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { root } from "../lib/config.js";
|
|
7
|
+
|
|
8
|
+
const projectRoot = root;
|
|
9
|
+
const notifyHookPath = join(projectRoot, "hooks", "claude-pet-notify.js");
|
|
10
|
+
const clearHookPath = join(projectRoot, "hooks", "claude-pet-clear.js");
|
|
11
|
+
const stopHookPath = join(projectRoot, "hooks", "claude-pet-stop.js");
|
|
12
|
+
const sessionStartHookPath = join(projectRoot, "scripts", "launch-desktop-if-needed.js");
|
|
13
|
+
const sessionEndHookPath = join(projectRoot, "scripts", "close-desktop-if-last-session.js");
|
|
14
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
15
|
+
const notifyCommand = `node ${JSON.stringify(notifyHookPath)}`;
|
|
16
|
+
const clearCommand = `node ${JSON.stringify(clearHookPath)}`;
|
|
17
|
+
const stopCommand = `node ${JSON.stringify(stopHookPath)}`;
|
|
18
|
+
const sessionStartCommand = `node ${JSON.stringify(sessionStartHookPath)}`;
|
|
19
|
+
const sessionEndCommand = `node ${JSON.stringify(sessionEndHookPath)}`;
|
|
20
|
+
|
|
21
|
+
async function readSettings() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(await readFile(settingsPath, "utf8"));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === "ENOENT") return {};
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasHook(hooks, command) {
|
|
31
|
+
return hooks.some(hook => hook?.type === "command" && hook?.command === command);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensureHook(settings, lifecycle, command) {
|
|
35
|
+
settings.hooks[lifecycle] ||= [];
|
|
36
|
+
let entry = settings.hooks[lifecycle].find(item => !item.matcher);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
entry = { hooks: [] };
|
|
39
|
+
settings.hooks[lifecycle].push(entry);
|
|
40
|
+
}
|
|
41
|
+
entry.hooks ||= [];
|
|
42
|
+
if (!hasHook(entry.hooks, command)) {
|
|
43
|
+
entry.hooks.push({ type: "command", command });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const settings = await readSettings();
|
|
49
|
+
settings.hooks ||= {};
|
|
50
|
+
|
|
51
|
+
ensureHook(settings, "Notification", notifyCommand);
|
|
52
|
+
ensureHook(settings, "PostToolUse", clearCommand);
|
|
53
|
+
ensureHook(settings, "Stop", stopCommand);
|
|
54
|
+
ensureHook(settings, "SessionStart", sessionStartCommand);
|
|
55
|
+
ensureHook(settings, "SessionEnd", sessionEndCommand);
|
|
56
|
+
|
|
57
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
58
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
59
|
+
try {
|
|
60
|
+
await copyFile(settingsPath, `${settingsPath}.before-claude-pet-${stamp}`);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code !== "ENOENT") throw error;
|
|
63
|
+
}
|
|
64
|
+
const tempPath = `${settingsPath}.claude-pet-${stamp}.tmp`;
|
|
65
|
+
await writeFile(tempPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
66
|
+
await rename(tempPath, settingsPath);
|
|
67
|
+
console.log(`Installed Claude Code hooks in ${settingsPath}`);
|
|
68
|
+
console.log(` Notification: ${notifyCommand}`);
|
|
69
|
+
console.log(` PostToolUse: ${clearCommand}`);
|
|
70
|
+
console.log(` Stop: ${stopCommand}`);
|
|
71
|
+
console.log(` SessionStart: ${sessionStartCommand}`);
|
|
72
|
+
console.log(` SessionEnd: ${sessionEndCommand}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch(error => {
|
|
76
|
+
console.error(error);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { recordSession } from "../lib/session-labels.js";
|
|
6
|
+
import { withFileLock } from "../lib/lock.js";
|
|
7
|
+
import { ensureOverlayBinary } from "../lib/overlay-binary.js";
|
|
8
|
+
import { isAlive, readPid, readStdinJson, removeFile } from "../lib/runtime.js";
|
|
9
|
+
import {
|
|
10
|
+
lifecycleLockDir,
|
|
11
|
+
healthUrl,
|
|
12
|
+
desktopUrl,
|
|
13
|
+
logDir,
|
|
14
|
+
overlayPath,
|
|
15
|
+
overlayPidFile,
|
|
16
|
+
root,
|
|
17
|
+
serverPidFile,
|
|
18
|
+
buildDir
|
|
19
|
+
} from "../lib/config.js";
|
|
20
|
+
|
|
21
|
+
function spawnDetached(command, args, logName) {
|
|
22
|
+
const child = spawn(command, args, {
|
|
23
|
+
cwd: root,
|
|
24
|
+
detached: true,
|
|
25
|
+
stdio: "ignore",
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
CLAUDE_PET_ROOT: root,
|
|
29
|
+
CLAUDE_PET_BUILD_DIR: buildDir,
|
|
30
|
+
CLAUDE_PET_DESKTOP_URL: desktopUrl
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
return child;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function processIsAlive(pidFile) {
|
|
38
|
+
const pid = await readPid(pidFile);
|
|
39
|
+
if (isAlive(pid)) return true;
|
|
40
|
+
await removeFile(pidFile);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function healthCheck() {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(healthUrl);
|
|
47
|
+
return response.ok;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const event = await readStdinJson();
|
|
55
|
+
await mkdir(logDir, { recursive: true });
|
|
56
|
+
await withFileLock(lifecycleLockDir, async () => {
|
|
57
|
+
await recordSession(event);
|
|
58
|
+
|
|
59
|
+
if (!(await healthCheck())) {
|
|
60
|
+
const serverProcess = spawnDetached(process.execPath, ["server.js"], "server.log");
|
|
61
|
+
await writeFile(serverPidFile, `${serverProcess.pid}\n`);
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await ensureOverlayBinary({ quiet: true });
|
|
66
|
+
|
|
67
|
+
if (!(await processIsAlive(overlayPidFile))) {
|
|
68
|
+
const overlayProcess = spawnDetached(overlayPath, [], "overlay.log");
|
|
69
|
+
await writeFile(overlayPidFile, `${overlayProcess.pid}\n`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch(error => {
|
|
75
|
+
console.error(error?.message || error);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cp, mkdir, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { ensureOverlayBinary, hasPrebuiltOverlay, commandExists, run } from "../lib/overlay-binary.js";
|
|
9
|
+
import { root } from "../lib/config.js";
|
|
10
|
+
|
|
11
|
+
const defaultAppDir = join(homedir(), "Library", "Application Support", "claude-pet", "app");
|
|
12
|
+
const markerFile = ".claude-pet-app";
|
|
13
|
+
const activeAppEnv = "CLAUDE_PET_ACTIVE_APP_DIR";
|
|
14
|
+
const appFiles = [
|
|
15
|
+
"bin",
|
|
16
|
+
"hooks",
|
|
17
|
+
"lib",
|
|
18
|
+
"macos",
|
|
19
|
+
"prebuilt",
|
|
20
|
+
"public",
|
|
21
|
+
"scripts",
|
|
22
|
+
"server.js",
|
|
23
|
+
"package.json",
|
|
24
|
+
"README.md"
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function parseAppDir() {
|
|
28
|
+
const index = process.argv.indexOf("--app-dir");
|
|
29
|
+
if (index >= 0) {
|
|
30
|
+
if (!process.argv[index + 1]) throw new Error("--app-dir requires a directory path.");
|
|
31
|
+
return process.argv[index + 1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const inline = process.argv.find(arg => arg.startsWith("--app-dir="));
|
|
35
|
+
if (inline) return inline.slice("--app-dir=".length);
|
|
36
|
+
|
|
37
|
+
return process.env.CLAUDE_PET_APP_DIR;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function pathExists(path) {
|
|
41
|
+
try {
|
|
42
|
+
await stat(path);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function samePath(left, right) {
|
|
50
|
+
try {
|
|
51
|
+
return await realpath(left) === await realpath(right);
|
|
52
|
+
} catch {
|
|
53
|
+
return resolve(left) === resolve(right);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function promptAppDir() {
|
|
58
|
+
const configured = parseAppDir();
|
|
59
|
+
if (configured) return resolve(configured);
|
|
60
|
+
|
|
61
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultAppDir;
|
|
62
|
+
|
|
63
|
+
const rl = createInterface({
|
|
64
|
+
input: process.stdin,
|
|
65
|
+
output: process.stdout
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const answer = await rl.question(`Install Claude Pet app files here? [${defaultAppDir}] `);
|
|
69
|
+
return resolve(answer.trim() || defaultAppDir);
|
|
70
|
+
} finally {
|
|
71
|
+
rl.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isDangerousAppDir(appDir) {
|
|
76
|
+
const resolved = resolve(appDir);
|
|
77
|
+
const home = homedir();
|
|
78
|
+
return [
|
|
79
|
+
"/",
|
|
80
|
+
home,
|
|
81
|
+
join(home, "Desktop"),
|
|
82
|
+
join(home, "Documents"),
|
|
83
|
+
join(home, "Downloads"),
|
|
84
|
+
join(home, "Applications"),
|
|
85
|
+
"/Applications",
|
|
86
|
+
"/Library",
|
|
87
|
+
"/System",
|
|
88
|
+
"/usr",
|
|
89
|
+
"/bin",
|
|
90
|
+
"/sbin",
|
|
91
|
+
"/etc",
|
|
92
|
+
"/var",
|
|
93
|
+
"/tmp"
|
|
94
|
+
].some(path => resolved === resolve(path));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function validateAppDir(appDir) {
|
|
98
|
+
if (isDangerousAppDir(appDir)) {
|
|
99
|
+
throw new Error(`Refusing to install app files directly into ${appDir}. Choose a dedicated Claude Pet folder instead.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!(await pathExists(appDir))) return;
|
|
103
|
+
|
|
104
|
+
const entries = await readdir(appDir);
|
|
105
|
+
if (entries.length === 0 || entries.includes(markerFile)) return;
|
|
106
|
+
|
|
107
|
+
throw new Error(`${appDir} is not empty and is not marked as a Claude Pet app directory. Choose an empty directory or an existing Claude Pet app directory.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function copyAppFiles(appDir) {
|
|
111
|
+
if (await samePath(root, appDir)) return;
|
|
112
|
+
|
|
113
|
+
await validateAppDir(appDir);
|
|
114
|
+
console.log(`Installing app files to ${appDir}...`);
|
|
115
|
+
await mkdir(appDir, { recursive: true });
|
|
116
|
+
await writeFile(join(appDir, markerFile), "Claude Pet managed app directory.\n");
|
|
117
|
+
|
|
118
|
+
for (const item of appFiles) {
|
|
119
|
+
const source = join(root, item);
|
|
120
|
+
if (!(await pathExists(source))) continue;
|
|
121
|
+
await rm(join(appDir, item), { recursive: true, force: true });
|
|
122
|
+
await cp(source, join(appDir, item), {
|
|
123
|
+
recursive: true,
|
|
124
|
+
force: true
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function runSetupFrom(appDir) {
|
|
130
|
+
return new Promise((resolvePromise, reject) => {
|
|
131
|
+
const script = join(appDir, "scripts", "setup.js");
|
|
132
|
+
const child = spawn(process.execPath, [script], {
|
|
133
|
+
cwd: appDir,
|
|
134
|
+
stdio: "inherit",
|
|
135
|
+
env: {
|
|
136
|
+
...process.env,
|
|
137
|
+
CLAUDE_PET_SKIP_APP_COPY: "1",
|
|
138
|
+
[activeAppEnv]: appDir
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
child.on("error", reject);
|
|
142
|
+
child.on("exit", code => {
|
|
143
|
+
if (code === 0) resolvePromise();
|
|
144
|
+
else reject(new Error(`${script} exited with ${code}`));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function installStableAppIfNeeded() {
|
|
150
|
+
if (process.env[activeAppEnv] && !parseAppDir()) return false;
|
|
151
|
+
if (process.env.CLAUDE_PET_SKIP_APP_COPY === "1") return false;
|
|
152
|
+
|
|
153
|
+
const appDir = await promptAppDir();
|
|
154
|
+
await copyAppFiles(appDir);
|
|
155
|
+
if (!(await samePath(root, appDir))) {
|
|
156
|
+
await runSetupFrom(appDir);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function assertPreflight() {
|
|
163
|
+
const failures = [];
|
|
164
|
+
if (process.platform !== "darwin") {
|
|
165
|
+
failures.push("macOS is required for the native desktop overlay.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
169
|
+
if (major < 18) {
|
|
170
|
+
failures.push(`Node.js 18 or newer is required. Current version: ${process.version}.`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!(await hasPrebuiltOverlay()) && !(await commandExists("swiftc"))) {
|
|
174
|
+
failures.push("Xcode command line tools are required because swiftc was not found.");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (failures.length > 0) {
|
|
178
|
+
throw new Error(`Setup cannot continue:\n- ${failures.join("\n- ")}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function main() {
|
|
183
|
+
console.log("Setting up Claude Pet...");
|
|
184
|
+
if (await installStableAppIfNeeded()) return;
|
|
185
|
+
await assertPreflight();
|
|
186
|
+
await ensureOverlayBinary();
|
|
187
|
+
await run(process.execPath, ["scripts/install-claude-hook.js"]);
|
|
188
|
+
|
|
189
|
+
console.log("");
|
|
190
|
+
console.log("Claude Pet is ready.");
|
|
191
|
+
console.log("Open a new Claude Code session and the pet will launch automatically.");
|
|
192
|
+
console.log("To launch it now, run: claude-pet launch");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main().catch(error => {
|
|
196
|
+
console.error(error?.message || error);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
});
|