claws-code 0.8.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.
Files changed (180) hide show
  1. package/.claude/commands/claws-auto.md +90 -0
  2. package/.claude/commands/claws-bin.md +28 -0
  3. package/.claude/commands/claws-cleanup.md +28 -0
  4. package/.claude/commands/claws-do.md +82 -0
  5. package/.claude/commands/claws-fix.md +40 -0
  6. package/.claude/commands/claws-goal.md +111 -0
  7. package/.claude/commands/claws-help.md +54 -0
  8. package/.claude/commands/claws-plan.md +103 -0
  9. package/.claude/commands/claws-report.md +29 -0
  10. package/.claude/commands/claws-status.md +37 -0
  11. package/.claude/commands/claws-update.md +32 -0
  12. package/.claude/commands/claws.md +64 -0
  13. package/.claude/rules/claws-default-behavior.md +76 -0
  14. package/.claude/settings.json +112 -0
  15. package/.claude/settings.local.json +19 -0
  16. package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
  17. package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
  18. package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
  19. package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
  20. package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
  21. package/CHANGELOG.md +1949 -0
  22. package/LICENSE +21 -0
  23. package/README.md +420 -0
  24. package/bin/cli.js +84 -0
  25. package/cli.js +223 -0
  26. package/docs/ARCHITECTURE.md +511 -0
  27. package/docs/event-protocol.md +588 -0
  28. package/docs/features.md +562 -0
  29. package/docs/guide.md +891 -0
  30. package/docs/index.html +716 -0
  31. package/docs/protocol.md +323 -0
  32. package/extension/.vscodeignore +15 -0
  33. package/extension/CHANGELOG.md +1906 -0
  34. package/extension/LICENSE +21 -0
  35. package/extension/README.md +137 -0
  36. package/extension/docs/features.md +424 -0
  37. package/extension/docs/protocol.md +197 -0
  38. package/extension/esbuild.mjs +25 -0
  39. package/extension/icon.png +0 -0
  40. package/extension/native/.metadata.json +10 -0
  41. package/extension/native/node-pty/LICENSE +69 -0
  42. package/extension/native/node-pty/README.md +165 -0
  43. package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
  44. package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
  45. package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
  46. package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
  47. package/extension/native/node-pty/lib/index.js +52 -0
  48. package/extension/native/node-pty/lib/index.js.map +1 -0
  49. package/extension/native/node-pty/lib/interfaces.js +7 -0
  50. package/extension/native/node-pty/lib/interfaces.js.map +1 -0
  51. package/extension/native/node-pty/lib/shared/conout.js +11 -0
  52. package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
  53. package/extension/native/node-pty/lib/terminal.js +190 -0
  54. package/extension/native/node-pty/lib/terminal.js.map +1 -0
  55. package/extension/native/node-pty/lib/types.js +7 -0
  56. package/extension/native/node-pty/lib/types.js.map +1 -0
  57. package/extension/native/node-pty/lib/unixTerminal.js +346 -0
  58. package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
  59. package/extension/native/node-pty/lib/utils.js +39 -0
  60. package/extension/native/node-pty/lib/utils.js.map +1 -0
  61. package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
  62. package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
  63. package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
  64. package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
  65. package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
  66. package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
  67. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  68. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
  69. package/extension/native/node-pty/package.json +64 -0
  70. package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  71. package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  72. package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  73. package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  74. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  75. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  76. package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  77. package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  78. package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
  79. package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
  80. package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
  81. package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  82. package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  83. package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  84. package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  85. package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
  86. package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
  87. package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
  88. package/extension/package-lock.json +605 -0
  89. package/extension/package.json +343 -0
  90. package/extension/scripts/bundle-native.mjs +104 -0
  91. package/extension/scripts/deploy-dev.mjs +60 -0
  92. package/extension/src/ansi-strip.ts +52 -0
  93. package/extension/src/backends/vscode/claws-pty.ts +483 -0
  94. package/extension/src/backends/vscode/status-bar.ts +99 -0
  95. package/extension/src/backends/vscode/vscode-backend.ts +282 -0
  96. package/extension/src/capture-store.ts +125 -0
  97. package/extension/src/event-log.ts +629 -0
  98. package/extension/src/event-schemas.ts +478 -0
  99. package/extension/src/extension.js +492 -0
  100. package/extension/src/extension.ts +873 -0
  101. package/extension/src/lifecycle-engine.ts +60 -0
  102. package/extension/src/lifecycle-rules.ts +171 -0
  103. package/extension/src/lifecycle-store.ts +506 -0
  104. package/extension/src/peer-registry.ts +176 -0
  105. package/extension/src/pipeline-registry.ts +82 -0
  106. package/extension/src/platform.ts +64 -0
  107. package/extension/src/protocol.ts +532 -0
  108. package/extension/src/server-config.ts +98 -0
  109. package/extension/src/server.ts +2210 -0
  110. package/extension/src/task-registry.ts +51 -0
  111. package/extension/src/terminal-backend.ts +211 -0
  112. package/extension/src/terminal-manager.ts +395 -0
  113. package/extension/src/topic-registry.ts +70 -0
  114. package/extension/src/topic-utils.ts +46 -0
  115. package/extension/src/transport.ts +45 -0
  116. package/extension/src/uninstall-cleanup.ts +232 -0
  117. package/extension/src/wave-registry.ts +314 -0
  118. package/extension/src/websocket-transport.ts +153 -0
  119. package/extension/tsconfig.json +23 -0
  120. package/lib/capabilities.js +145 -0
  121. package/lib/dry-run.js +43 -0
  122. package/lib/install.js +1018 -0
  123. package/lib/mcp-setup.js +92 -0
  124. package/lib/platform.js +240 -0
  125. package/lib/preflight.js +152 -0
  126. package/lib/shell-hook.js +343 -0
  127. package/lib/uninstall.js +162 -0
  128. package/lib/verify.js +166 -0
  129. package/mcp_server.js +3529 -0
  130. package/package.json +48 -0
  131. package/rules/claws-default-behavior.md +72 -0
  132. package/scripts/_helpers/atomic-file.mjs +137 -0
  133. package/scripts/_helpers/fix-repair.js +64 -0
  134. package/scripts/_helpers/json-safe.mjs +218 -0
  135. package/scripts/bump-version.sh +84 -0
  136. package/scripts/codegen/gen-docs.mjs +61 -0
  137. package/scripts/codegen/gen-json-schema.mjs +62 -0
  138. package/scripts/codegen/gen-mcp-tools.mjs +358 -0
  139. package/scripts/codegen/gen-types.mjs +172 -0
  140. package/scripts/codegen/index.mjs +42 -0
  141. package/scripts/dev-hooks/check-extension-dirs.js +77 -0
  142. package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
  143. package/scripts/dev-hooks/check-stale-main.js +55 -0
  144. package/scripts/dev-hooks/check-tag-pushed.js +51 -0
  145. package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
  146. package/scripts/dev-vsix-install.sh +60 -0
  147. package/scripts/fix.sh +702 -0
  148. package/scripts/gen-client-types.mjs +81 -0
  149. package/scripts/git-hooks/pre-commit +31 -0
  150. package/scripts/hooks/lifecycle-state.js +61 -0
  151. package/scripts/hooks/package.json +4 -0
  152. package/scripts/hooks/post-tool-use-claws.js +292 -0
  153. package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
  154. package/scripts/hooks/pre-tool-use-claws.js +206 -0
  155. package/scripts/hooks/session-start-claws.js +97 -0
  156. package/scripts/hooks/stop-claws.js +88 -0
  157. package/scripts/inject-claude-md.js +205 -0
  158. package/scripts/inject-dev-hooks.js +96 -0
  159. package/scripts/inject-global-claude-md.js +140 -0
  160. package/scripts/inject-settings-hooks.js +370 -0
  161. package/scripts/install.ps1 +146 -0
  162. package/scripts/install.sh +1729 -0
  163. package/scripts/monitor-arm-watch.js +155 -0
  164. package/scripts/rebuild-node-pty.sh +245 -0
  165. package/scripts/report.sh +232 -0
  166. package/scripts/shell-hook.fish +164 -0
  167. package/scripts/shell-hook.ps1 +33 -0
  168. package/scripts/shell-hook.sh +232 -0
  169. package/scripts/stream-events.js +399 -0
  170. package/scripts/terminal-wrapper.sh +36 -0
  171. package/scripts/test-enforcement.sh +132 -0
  172. package/scripts/test-install.sh +174 -0
  173. package/scripts/test-installer-parity.sh +135 -0
  174. package/scripts/test-template-enforcement.sh +76 -0
  175. package/scripts/uninstall.sh +143 -0
  176. package/scripts/update.sh +337 -0
  177. package/scripts/verify-release.sh +323 -0
  178. package/scripts/verify-wrapped.sh +194 -0
  179. package/templates/CLAUDE.global.md +135 -0
  180. package/templates/CLAUDE.project.md +37 -0
package/lib/install.js ADDED
@@ -0,0 +1,1018 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const { findAllEditorClis, longPathPreflight, dryRunLog, detectPlatformArch } = require('./platform.js');
9
+ const preflight = require('./preflight.js');
10
+ const { installCommands, installSkills, installRules, installCapabilities } = require('./capabilities.js');
11
+ const { injectShellHook } = require('./shell-hook.js');
12
+ const { writeMcpJson } = require('./mcp-setup.js');
13
+ const { verify } = require('./verify.js');
14
+
15
+ const HOME = os.homedir();
16
+ const REPO_ROOT = path.resolve(__dirname, '..');
17
+
18
+ const TOTAL = 8;
19
+ let _stepN = 0;
20
+
21
+ function _step(label) { _stepN++; process.stdout.write(`\n\x1b[1m\x1b[34m[${_stepN}/${TOTAL}]\x1b[0m ${label}\n`); }
22
+ function _ok(msg) { process.stdout.write(` \x1b[32m✓\x1b[0m ${msg}\n`); }
23
+ function _warn(msg) { process.stdout.write(` \x1b[33m!\x1b[0m ${msg}\n`); }
24
+ function _info(msg) { process.stdout.write(` \x1b[2m${msg}\x1b[0m\n`); }
25
+
26
+ const _SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
27
+ function _sleepSync(ms) { Atomics.wait(_SLEEP_BUF, 0, 0, ms); }
28
+
29
+ /**
30
+ * Check if a process has CLAWS_TERMINAL_CORR_ID in its environment.
31
+ * Worker children have it set (spawned with it by the extension); the outer
32
+ * orchestrator running the installer does not. Only kills children.
33
+ * @param {number} pid
34
+ * @param {string} platform os.platform() result
35
+ * @returns {boolean}
36
+ */
37
+ function _hasCorrelationId(pid, platform) {
38
+ try {
39
+ if (platform === 'darwin') {
40
+ // ps -E -p <pid> appends environment variables to each process line on macOS
41
+ const r = spawnSync('ps', ['-E', '-p', String(pid)], { encoding: 'utf8', stdio: 'pipe' });
42
+ return !!(r.stdout && r.stdout.includes('CLAWS_TERMINAL_CORR_ID'));
43
+ } else if (platform === 'linux') {
44
+ // /proc/<pid>/environ is NUL-delimited env for each process
45
+ const environ = fs.readFileSync(`/proc/${pid}/environ`, 'latin1');
46
+ return environ.includes('CLAWS_TERMINAL_CORR_ID');
47
+ }
48
+ } catch (_) {}
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Kill stale worker mcp_server.js and spawn-helper processes from prior sessions.
54
+ * Safe: only kills processes that (a) reference this project's .claws-bin/mcp_server.js
55
+ * path AND (b) have CLAWS_TERMINAL_CORR_ID in their environment (= worker children,
56
+ * not the outer orchestrator running this install). Idempotent — running on a fresh
57
+ * system with no stale workers is a no-op.
58
+ * @param {string} projectRoot
59
+ * @param {boolean} [dryRun]
60
+ */
61
+ function _cleanStaleWorkerProcesses(projectRoot, dryRun) {
62
+ const platform = os.platform();
63
+ const mcpServerPath = path.join(projectRoot, '.claws-bin', 'mcp_server.js');
64
+ let killedProcs = 0;
65
+ let clearedPids = 0;
66
+
67
+ if (platform === 'darwin' || platform === 'linux') {
68
+ // ── mcp_server.js processes ─────────────────────────────────────────────
69
+ const pgrepMcp = spawnSync('pgrep', ['-af', 'mcp_server\\.js'], { encoding: 'utf8', stdio: 'pipe' });
70
+ if (pgrepMcp.stdout) {
71
+ for (const line of pgrepMcp.stdout.split('\n')) {
72
+ if (!line.trim()) continue;
73
+ // pgrep -af output: "<pid> <command line>"
74
+ const spaceIdx = line.indexOf(' ');
75
+ if (spaceIdx === -1) continue;
76
+ const pid = parseInt(line.slice(0, spaceIdx), 10);
77
+ const cmdLine = line.slice(spaceIdx + 1);
78
+ if (!Number.isFinite(pid)) continue;
79
+ // Must reference this project's mcp_server.js — full path match prevents
80
+ // collateral kills of other projects' MCP servers
81
+ if (!cmdLine.includes(mcpServerPath)) continue;
82
+ // Must be a worker child (CLAWS_TERMINAL_CORR_ID in env) — the outer
83
+ // orchestrator running this install does NOT have that env var
84
+ if (!_hasCorrelationId(pid, platform)) continue;
85
+ if (dryRun) {
86
+ process.stdout.write(` [claws-clean] would kill PID ${pid}: ${cmdLine.slice(0, 80)}\n`);
87
+ } else {
88
+ process.stdout.write(` [claws-clean] kill PID ${pid}: ${cmdLine.slice(0, 80)}\n`);
89
+ spawnSync('kill', ['-TERM', String(pid)], { stdio: 'pipe' });
90
+ _sleepSync(2000); // 2 s grace before SIGKILL
91
+ try { process.kill(pid, 0); spawnSync('kill', ['-KILL', String(pid)], { stdio: 'pipe' }); } catch (_) {}
92
+ }
93
+ killedProcs++;
94
+ }
95
+ }
96
+
97
+ // ── spawn-helper processes (Mac/Linux only — Windows uses winpty/conpty) ─
98
+ const pgrepHelper = spawnSync('pgrep', ['-af', 'spawn-helper'], { encoding: 'utf8', stdio: 'pipe' });
99
+ if (pgrepHelper.stdout) {
100
+ for (const line of pgrepHelper.stdout.split('\n')) {
101
+ if (!line.trim()) continue;
102
+ const spaceIdx = line.indexOf(' ');
103
+ if (spaceIdx === -1) continue;
104
+ const pid = parseInt(line.slice(0, spaceIdx), 10);
105
+ if (!Number.isFinite(pid)) continue;
106
+ // Only kill spawn-helpers that are worker children (CORR_ID in env)
107
+ if (!_hasCorrelationId(pid, platform)) continue;
108
+ if (dryRun) {
109
+ process.stdout.write(` [claws-clean] would kill spawn-helper PID ${pid}\n`);
110
+ } else {
111
+ process.stdout.write(` [claws-clean] kill spawn-helper PID ${pid}\n`);
112
+ spawnSync('kill', ['-TERM', String(pid)], { stdio: 'pipe' });
113
+ }
114
+ killedProcs++;
115
+ }
116
+ }
117
+ } else if (platform === 'win32') {
118
+ // Windows: PowerShell CIM query — Get-CimInstance Win32_Process filters node.exe
119
+ // by CommandLine; Stop-Process kills matching PIDs. Skip our own PID to be safe.
120
+ const ownPid = process.pid;
121
+ const escapedPath = mcpServerPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
122
+ const psScript = [
123
+ `$myPid = ${ownPid}`,
124
+ `$mcpPath = '${escapedPath}'`,
125
+ `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | ForEach-Object {`,
126
+ ` $proc = $_`,
127
+ ` if ($proc.CommandLine -and $proc.CommandLine.Contains($mcpPath) -and $proc.ProcessId -ne $myPid) {`,
128
+ ` Write-Output ("KILL:" + $proc.ProcessId + ":" + $proc.CommandLine.Substring(0, [Math]::Min(80, $proc.CommandLine.Length)))`,
129
+ ` Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue`,
130
+ ` }`,
131
+ `}`,
132
+ ].join('\n');
133
+ const psResult = spawnSync('powershell', ['-NoProfile', '-Command', psScript],
134
+ { encoding: 'utf8', stdio: 'pipe' });
135
+ if (psResult.stdout) {
136
+ for (const line of psResult.stdout.split('\n')) {
137
+ const l = line.trim();
138
+ if (!l.startsWith('KILL:')) continue;
139
+ process.stdout.write(` [claws-clean] killed ${l.slice(5)}\n`);
140
+ killedProcs++;
141
+ }
142
+ }
143
+ }
144
+
145
+ // ── Clear stale .claws/sidecar.pid if the recorded PID is dead ─────────────
146
+ const sidecarpidPath = path.join(projectRoot, '.claws', 'sidecar.pid');
147
+ if (fs.existsSync(sidecarpidPath)) {
148
+ try {
149
+ const sidecarpid = parseInt(fs.readFileSync(sidecarpidPath, 'utf8').trim(), 10);
150
+ let alive = false;
151
+ try { process.kill(sidecarpid, 0); alive = true; } catch (_) {}
152
+ if (!alive) {
153
+ if (!dryRun) fs.unlinkSync(sidecarpidPath);
154
+ process.stdout.write(` [claws-clean] cleared stale sidecar.pid (PID ${sidecarpid} dead)\n`);
155
+ clearedPids++;
156
+ }
157
+ } catch (_) {}
158
+ }
159
+
160
+ process.stdout.write(` [claws-clean] killed ${killedProcs} stale processes, cleared ${clearedPids} stale pid files\n`);
161
+ }
162
+
163
+ /**
164
+ * Run the 8-phase installer.
165
+ * @param {object} [opts]
166
+ * @param {boolean} [opts.dryRun]
167
+ * @param {boolean} [opts.noHooks]
168
+ * @param {string|null} [opts.vscodeCli]
169
+ * @param {boolean} [opts.force]
170
+ */
171
+ function run(opts = {}) {
172
+ const { dryRun = false, noHooks = false, vscodeCli: cliOverride = null } = opts;
173
+ const projectRoot = process.cwd();
174
+ _stepN = 0;
175
+
176
+ _printBanner();
177
+
178
+ // ── Pre-install: kill stale worker processes from prior sessions ────────────
179
+ _cleanStaleWorkerProcesses(projectRoot, dryRun);
180
+
181
+ const lpWarn = longPathPreflight(HOME);
182
+ if (lpWarn) _warn(lpWarn);
183
+
184
+ // ── Phase 1: validate env ──────────────────────────────────────────────────
185
+ _step('Validate environment');
186
+ const { failures: preflightFailures, warnings: preflightWarnings } =
187
+ preflight.run(cliOverride ? { vscodeCli: cliOverride } : {});
188
+ for (const w of preflightWarnings) _warn(w);
189
+ if (preflightFailures.length > 0) {
190
+ for (const f of preflightFailures) process.stderr.write(` \x1b[31m✗\x1b[0m ${f}\n`);
191
+ process.stderr.write('\nPreflight failed — fix the above before re-running.\n');
192
+ process.exit(1);
193
+ }
194
+ _info(`Prebuild key: ${detectPlatformArch()}`);
195
+ _ok('Environment OK');
196
+
197
+ // ── Phase 2: prepare .claws-bin/ ──────────────────────────────────────────
198
+ _step('Prepare .claws-bin/');
199
+ _prepareCLawsBin(projectRoot, dryRun);
200
+ _ok('.claws-bin/ ready');
201
+
202
+ // W7h-24: CLAWS_GLOBAL_MCP=1 → also register claws in ~/.claude/settings.json mcpServers.
203
+ // Matches install.sh:1150-1163.
204
+ if (process.env.CLAWS_GLOBAL_MCP === '1') {
205
+ _registerGlobalMcp(dryRun);
206
+ }
207
+
208
+ // ── Phase 3: install commands + Bug 1 sweep (project-local, matches bash) ──
209
+ _step('Install commands (Bug 1 sweep)');
210
+ installCommands(projectRoot, dryRun);
211
+ _writeInstallCommand(projectRoot, dryRun);
212
+ _ok('Commands installed');
213
+
214
+ // ── Phase 4: install skills + Bug 2 sweep ────────────────────────────────
215
+ _step('Install skills + rules (Bug 2 sweep)');
216
+ installSkills(projectRoot, dryRun);
217
+ installRules(projectRoot, dryRun);
218
+ _ok('Skills and rules installed (project-local)');
219
+
220
+ // W7h-24: CLAWS_GLOBAL_CONFIG=1 → also install commands/skills/rules into ~/.claude/
221
+ if (process.env.CLAWS_GLOBAL_CONFIG === '1') {
222
+ _step('Install global capabilities (CLAWS_GLOBAL_CONFIG=1)');
223
+ installCapabilities(HOME, dryRun);
224
+ _ok('Global capabilities installed in ~/.claude/');
225
+ }
226
+
227
+ // ── Phase 5: CLAUDE.md injection (after commands so CMDS_LIST is populated) ─
228
+ _step('Inject CLAUDE.md block');
229
+ _injectClaudeMd(projectRoot, dryRun);
230
+ _ok('CLAUDE.md updated');
231
+
232
+ // ── Phase 6: hooks registration ───────────────────────────────────────────
233
+ if (noHooks) {
234
+ _step('Register lifecycle hooks (--no-hooks: skipped)');
235
+ _info('Skipping ~/.claude/settings.json hook registration');
236
+ } else {
237
+ _step('Register lifecycle hooks');
238
+ const hooksOk = _injectHooks(projectRoot, dryRun);
239
+ if (hooksOk) _ok('Hooks registered');
240
+ }
241
+
242
+ // ── Phase 6b: dev-discipline hooks (contributor environments) ─────────────
243
+ // W7h-31: mirrors install.sh:1301-1332 step 6b.
244
+ // Install when: (a) CLAWS_INSTALL_DEV_HOOKS=1, OR (b) the project IS the Claws
245
+ // source tree — detected by checking projectRoot (matches bash's $PROJECT_ROOT check).
246
+ const isDevTree = fs.existsSync(path.join(projectRoot, 'scripts', 'install.sh')) &&
247
+ fs.existsSync(path.join(projectRoot, 'extension', 'src'));
248
+ if ((process.env.CLAWS_INSTALL_DEV_HOOKS === '1' || isDevTree) && !dryRun) {
249
+ _installDevHooks(projectRoot);
250
+ } else if ((process.env.CLAWS_INSTALL_DEV_HOOKS === '1' || isDevTree) && dryRun) {
251
+ dryRunLog('install dev-hooks (contributor environment detected or CLAWS_INSTALL_DEV_HOOKS=1)');
252
+ }
253
+
254
+ // ── Phase 7: VS Code extension install ───────────────────────────────────
255
+ _step('Install VS Code extension');
256
+ const editorClis = cliOverride
257
+ ? [{ label: 'override', cliPath: cliOverride }]
258
+ : findAllEditorClis();
259
+ _installExtension(editorClis, dryRun);
260
+
261
+ // ── Phase 8: shell rc-file hook ───────────────────────────────────────────
262
+ if (noHooks) {
263
+ _step('Inject shell hook (--no-hooks / CLAWS_NO_GLOBAL_HOOKS=1: skipped)');
264
+ _info('Skipping shell rc-file hook injection');
265
+ } else {
266
+ _step('Inject shell hook');
267
+ if (dryRun) {
268
+ dryRunLog('inject claws shell hook into rc file');
269
+ } else {
270
+ try {
271
+ injectShellHook(REPO_ROOT, dryRun);
272
+ _ok('Shell hook injected');
273
+ } catch (err) {
274
+ _warn(`Shell hook injection failed: ${err.message}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ // ── Post-install verify ───────────────────────────────────────────────────
280
+ if (!dryRun) {
281
+ const failures = verify(projectRoot);
282
+ if (failures.length > 0) {
283
+ process.stdout.write('\n');
284
+ for (const f of failures) _warn(f);
285
+ }
286
+ }
287
+
288
+ _printSuccess();
289
+ }
290
+
291
+ // ─────────────────────────────────────────────────────────────────────────────
292
+
293
+ function _prepareCLawsBin(projectRoot, dryRun) {
294
+ const clawsBin = path.join(projectRoot, '.claws-bin');
295
+ const hooksDir = path.join(clawsBin, 'hooks');
296
+
297
+ const filesToCopy = [
298
+ { src: path.join(REPO_ROOT, 'mcp_server.js'), name: 'mcp_server.js' },
299
+ { src: path.join(REPO_ROOT, 'scripts', 'stream-events.js'), name: 'stream-events.js' },
300
+ { src: path.join(REPO_ROOT, 'scripts', 'monitor-arm-watch.js'), name: 'monitor-arm-watch.js' },
301
+ { src: path.join(REPO_ROOT, 'scripts', 'shell-hook.sh'), name: 'shell-hook.sh' },
302
+ ];
303
+
304
+ if (dryRun) {
305
+ dryRunLog(`mkdir ${clawsBin}`);
306
+ dryRunLog(`mkdir ${hooksDir}`);
307
+ for (const { src, name } of filesToCopy) {
308
+ dryRunLog(`copy ${src} → .claws-bin/${name}`);
309
+ }
310
+ dryRunLog(`write .claws-bin/package.json shim`);
311
+ dryRunLog(`write .claws-bin/README.md`);
312
+ if (process.env.CLAWS_SKIP_EXTENSION_COPY !== '1') {
313
+ _copyExtensionArtifacts(projectRoot, true);
314
+ }
315
+ writeMcpJson(projectRoot, dryRun);
316
+ _updateGitignore(projectRoot, dryRun);
317
+ _updateVscodeExtensions(projectRoot, dryRun);
318
+ return;
319
+ }
320
+
321
+ // Guard against reparse-point artifacts from macOS tarballs extracted on Windows.
322
+ // rmSync clears any symlink/reparse-point that would cause EPERM on mkdir.
323
+ try {
324
+ fs.rmSync(clawsBin, { recursive: true, force: true });
325
+ } catch (_) { /* ignore — dir may not exist or may be partially removable */ }
326
+
327
+ try {
328
+ fs.mkdirSync(clawsBin, { recursive: true });
329
+ fs.mkdirSync(hooksDir, { recursive: true });
330
+ } catch (e) {
331
+ if (e.code === 'EEXIST') {
332
+ process.stdout.write(' [warn] .claws-bin/ directory conflict after cleanup — continuing\n');
333
+ } else {
334
+ throw e;
335
+ }
336
+ }
337
+
338
+ for (const { src, name } of filesToCopy) {
339
+ if (fs.existsSync(src)) {
340
+ fs.copyFileSync(src, path.join(clawsBin, name));
341
+ }
342
+ }
343
+
344
+ // Copy scripts/hooks/ into .claws-bin/hooks/ — atomic to avoid partial state
345
+ // if the process is killed mid-copy (M-09 pattern from atomic-file.mjs).
346
+ const hooksSrc = path.join(REPO_ROOT, 'scripts', 'hooks');
347
+ if (fs.existsSync(hooksSrc)) {
348
+ _copyDirAtomic(hooksSrc, hooksDir);
349
+ }
350
+
351
+ // Copy schemas/ (MCP tool schemas + JSON schemas + type definitions)
352
+ const schemasSrc = path.join(REPO_ROOT, 'schemas');
353
+ if (fs.existsSync(schemasSrc)) {
354
+ fs.cpSync(schemasSrc, path.join(clawsBin, 'schemas'), { recursive: true });
355
+ }
356
+
357
+ // Copy claws-sdk.js (typed publish helpers for worker scripts)
358
+ const sdkSrc = path.join(REPO_ROOT, 'claws-sdk.js');
359
+ if (fs.existsSync(sdkSrc)) {
360
+ fs.copyFileSync(sdkSrc, path.join(clawsBin, 'claws-sdk.js'));
361
+ }
362
+
363
+ // CommonJS shim so Node treats .claws-bin as CJS even in ESM-default workspaces
364
+ const pkgShim = path.join(clawsBin, 'package.json');
365
+ if (!fs.existsSync(pkgShim)) {
366
+ fs.writeFileSync(pkgShim, '{"type":"commonjs"}\n', 'utf8');
367
+ }
368
+
369
+ // README so teammates can see what's in .claws-bin/
370
+ fs.writeFileSync(path.join(clawsBin, 'README.md'),
371
+ '# .claws-bin/\n\nProject-local Claws runtime. Auto-generated by the installer — do not edit.\n', 'utf8');
372
+
373
+ // Copy extension/ artifacts into .claws-bin/extension/ for visibility (W7h-13).
374
+ // VS Code loads from ~/.vscode/extensions/; this reference copy lets teammates
375
+ // confirm the installed version and see the bundle. Matches install.sh:968-983.
376
+ // Opt out with CLAWS_SKIP_EXTENSION_COPY=1.
377
+ if (process.env.CLAWS_SKIP_EXTENSION_COPY !== '1') {
378
+ _copyExtensionArtifacts(projectRoot, dryRun);
379
+ }
380
+
381
+ // Write/update .mcp.json so the MCP server is registered in the project
382
+ writeMcpJson(projectRoot, dryRun);
383
+
384
+ // Add Claws runtime paths to .gitignore (W7h-9)
385
+ _updateGitignore(projectRoot, dryRun);
386
+
387
+ // Add neunaha.claws to .vscode/extensions.json recommendations (W7h-10)
388
+ _updateVscodeExtensions(projectRoot, dryRun);
389
+ }
390
+
391
+ /**
392
+ * Append Claws runtime entries to <projectRoot>/.gitignore if not already present.
393
+ * Matches install.sh lines 1094-1106.
394
+ * @param {string} projectRoot
395
+ * @param {boolean} [dryRun]
396
+ */
397
+ function _updateGitignore(projectRoot, dryRun) {
398
+ const gitignore = path.join(projectRoot, '.gitignore');
399
+ const entries = ['.claws/', '.mcp.json', '.claws-bin/'];
400
+
401
+ if (dryRun) {
402
+ dryRunLog(`update ${gitignore} with claws entries`);
403
+ return;
404
+ }
405
+
406
+ let existing = '';
407
+ if (fs.existsSync(gitignore)) {
408
+ existing = fs.readFileSync(gitignore, 'utf8');
409
+ }
410
+
411
+ const toAdd = entries.filter(e => !existing.includes(e));
412
+ if (toAdd.length === 0) return;
413
+
414
+ const append = '\n# Claws runtime artifacts (auto-added by installer)\n' + toAdd.join('\n') + '\n';
415
+ const tmp = gitignore + '.claws-tmp.' + process.pid;
416
+ fs.writeFileSync(tmp, existing + append, 'utf8');
417
+ fs.renameSync(tmp, gitignore);
418
+ _ok(`.gitignore updated (${toAdd.join(', ')})`);
419
+ }
420
+
421
+ /**
422
+ * Merge neunaha.claws into .vscode/extensions.json workspace recommendations.
423
+ * Tolerates JSONC comments via inline strip. Matches install.sh lines 1113-1144.
424
+ * @param {string} projectRoot
425
+ * @param {boolean} [dryRun]
426
+ */
427
+ function _updateVscodeExtensions(projectRoot, dryRun) {
428
+ const vscodeDir = path.join(projectRoot, '.vscode');
429
+ const extFile = path.join(vscodeDir, 'extensions.json');
430
+ const EXT_ID = 'neunaha.claws';
431
+
432
+ if (dryRun) {
433
+ dryRunLog(`merge ${EXT_ID} into ${extFile}`);
434
+ return;
435
+ }
436
+
437
+ let config = { recommendations: [] };
438
+ if (fs.existsSync(extFile)) {
439
+ try {
440
+ const raw = fs.readFileSync(extFile, 'utf8');
441
+ config = JSON.parse(_stripJsonc(raw));
442
+ } catch {
443
+ _warn('.vscode/extensions.json is malformed — skipping');
444
+ return;
445
+ }
446
+ }
447
+
448
+ if (!Array.isArray(config.recommendations)) config.recommendations = [];
449
+ if (config.recommendations.includes(EXT_ID)) return;
450
+
451
+ config.recommendations.push(EXT_ID);
452
+ fs.mkdirSync(vscodeDir, { recursive: true });
453
+ const tmp = extFile + '.claws-tmp.' + process.pid;
454
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
455
+ fs.renameSync(tmp, extFile);
456
+ _ok('.vscode/extensions.json updated');
457
+ }
458
+
459
+ // Simple JSONC comment stripper — removes line comments, block comments,
460
+ // and trailing commas. Sufficient for .vscode/*.json written by editors.
461
+ function _stripJsonc(text) {
462
+ return text
463
+ .replace(/\/\*[\s\S]*?\*\//g, '')
464
+ .replace(/\/\/[^\n]*/g, '')
465
+ .replace(/,(\s*[}\]])/g, '$1');
466
+ }
467
+
468
+ /**
469
+ * Register claws MCP server in ~/.claude/settings.json mcpServers (W7h-24).
470
+ * Matches install.sh:1150-1163. Idempotent — overwrites existing claws entry.
471
+ * @param {boolean} [dryRun]
472
+ */
473
+ function _registerGlobalMcp(dryRun = false) {
474
+ const settingsPath = path.join(HOME, '.claude', 'settings.json');
475
+ const mcpServerPath = path.join(REPO_ROOT, 'mcp_server.js');
476
+
477
+ if (dryRun) {
478
+ dryRunLog(`register claws MCP in ${settingsPath} (CLAWS_GLOBAL_MCP=1)`);
479
+ return;
480
+ }
481
+
482
+ let cfg = {};
483
+ if (fs.existsSync(settingsPath)) {
484
+ try { cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch (_) {}
485
+ }
486
+ if (!cfg.mcpServers) cfg.mcpServers = {};
487
+ cfg.mcpServers.claws = { command: 'node', args: [mcpServerPath] };
488
+
489
+ fs.mkdirSync(path.join(HOME, '.claude'), { recursive: true });
490
+ const tmp = settingsPath + '.claws-tmp.' + process.pid;
491
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
492
+ fs.renameSync(tmp, settingsPath);
493
+ _ok('Global MCP registered in ~/.claude/settings.json');
494
+ }
495
+
496
+ function _injectClaudeMd(projectRoot, dryRun) {
497
+ const injectProject = path.join(REPO_ROOT, 'scripts', 'inject-claude-md.js');
498
+ const injectGlobal = path.join(REPO_ROOT, 'scripts', 'inject-global-claude-md.js');
499
+
500
+ // inject-claude-md.js does not support --dry-run; guard the call ourselves.
501
+ if (dryRun) {
502
+ dryRunLog(`inject CLAWS:BEGIN block into ${path.join(projectRoot, 'CLAUDE.md')}`);
503
+ } else if (fs.existsSync(injectProject)) {
504
+ const r = spawnSync(process.execPath, [injectProject, projectRoot], {
505
+ cwd: REPO_ROOT, stdio: 'inherit', encoding: 'utf8',
506
+ });
507
+ if (r.status !== 0) _warn('inject-claude-md.js failed — CLAUDE.md may be stale');
508
+ }
509
+
510
+ // inject-global-claude-md.js natively supports --dry-run.
511
+ if (fs.existsSync(injectGlobal)) {
512
+ const extraArgs = dryRun ? ['--dry-run'] : [];
513
+ const r = spawnSync(process.execPath, [injectGlobal, ...extraArgs], {
514
+ cwd: REPO_ROOT, stdio: 'inherit', encoding: 'utf8',
515
+ });
516
+ if (r.status !== 0 && !dryRun) _warn('inject-global-claude-md.js failed — ~/.claude/CLAUDE.md may be stale');
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Copy hook scripts from <repo>/scripts/hooks/*.js to a stable global directory
522
+ * ($HOME/.claude/claws/hooks/) so that hooks registered in ~/.claude/settings.json
523
+ * survive project moves or deletions (D02 / W7h-2 Option B).
524
+ * Uses atomic temp+rename per file to avoid partial-copy races.
525
+ * @param {boolean} [dryRun]
526
+ * @returns {string} absolute path to the global hooks dir
527
+ */
528
+ function installGlobalHooks(dryRun = false) {
529
+ // W7h-30B: return the PARENT of hooks/ (~/.claude/claws), not the hooks/ dir itself.
530
+ // inject-settings-hooks.js receives this as CLAWS_BIN and does path.join(CLAWS_BIN, 'hooks', script),
531
+ // so passing the parent produces the correct ~/.claude/claws/hooks/<script>.js path.
532
+ // Passing hooks/ directly caused ~/.claude/claws/hooks/hooks/<script>.js duplication.
533
+ const clawsParentDir = path.join(HOME, '.claude', 'claws');
534
+ const globalHooksDir = path.join(clawsParentDir, 'hooks');
535
+ const srcHooksDir = path.join(REPO_ROOT, 'scripts', 'hooks');
536
+
537
+ if (dryRun) {
538
+ dryRunLog(`mkdir ${globalHooksDir}`);
539
+ if (fs.existsSync(srcHooksDir)) {
540
+ for (const f of fs.readdirSync(srcHooksDir)) {
541
+ if (f.endsWith('.js')) dryRunLog(`copy hooks/${f} → ${globalHooksDir}/${f}`);
542
+ }
543
+ }
544
+ return clawsParentDir;
545
+ }
546
+
547
+ fs.mkdirSync(globalHooksDir, { recursive: true });
548
+
549
+ if (fs.existsSync(srcHooksDir)) {
550
+ for (const file of fs.readdirSync(srcHooksDir)) {
551
+ if (!file.endsWith('.js')) continue;
552
+ const src = path.join(srcHooksDir, file);
553
+ const tmp = path.join(globalHooksDir, file + '.claws-tmp.' + process.pid);
554
+ const dst = path.join(globalHooksDir, file);
555
+ fs.copyFileSync(src, tmp);
556
+ fs.renameSync(tmp, dst);
557
+ }
558
+ }
559
+
560
+ return clawsParentDir;
561
+ }
562
+
563
+ function _injectHooks(projectRoot, dryRun, opts = {}) {
564
+ const spawnFn = opts.spawnFn || spawnSync;
565
+ const script = path.join(REPO_ROOT, 'scripts', 'inject-settings-hooks.js');
566
+ const extraArgs = dryRun ? ['--dry-run', '--update'] : ['--update'];
567
+
568
+ if (!fs.existsSync(script)) { _warn('inject-settings-hooks.js not found — hooks skipped'); return false; }
569
+
570
+ // D02 / W7h-2: install hook scripts to a stable global location so that
571
+ // settings.json hook commands survive project moves or deletions.
572
+ const globalHooksDir = installGlobalHooks(dryRun);
573
+
574
+ const r = spawnFn(process.execPath, [script, globalHooksDir, ...extraArgs], {
575
+ cwd: REPO_ROOT, stdio: 'inherit', encoding: 'utf8',
576
+ });
577
+ if (r.status !== 0 && !dryRun) {
578
+ _warn('inject-settings-hooks.js failed — hooks may not be registered');
579
+ _warn('Fix: run `node scripts/inject-settings-hooks.js` manually, then verify ~/.claude/settings.json');
580
+ return false;
581
+ }
582
+ return true;
583
+ }
584
+
585
+ function _installExtension(editorClis, dryRun) {
586
+ const extDir = path.join(REPO_ROOT, 'extension');
587
+ const isWin = process.platform === 'win32';
588
+ // shell:true on Windows passes the whole command through cmd.exe, which splits on unquoted spaces.
589
+ // Wrap any path that contains spaces so cmd.exe treats it as one token.
590
+ const wq = (p) => (isWin && p.includes(' ') ? `"${p}"` : p);
591
+
592
+ if (dryRun) {
593
+ dryRunLog('check electron ABI drift (extension/native/.metadata.json vs installed editor)');
594
+ dryRunLog('npm install --no-fund --no-audit --loglevel=error (cwd: extension/)');
595
+ dryRunLog('npm run build (cwd: extension/)');
596
+ dryRunLog('npx @vscode/vsce package --skip-license --no-git-tag-version --no-update-package-json --no-dependencies -o <tmpdir>/claws-code-<version>.vsix');
597
+ dryRunLog('check VSIX size >= 50 KB');
598
+ for (const { label, cliPath } of editorClis) {
599
+ dryRunLog(`${cliPath} --install-extension <tmpdir>/claws-code-<version>.vsix --force [${label}]`);
600
+ }
601
+ return;
602
+ }
603
+
604
+ if (editorClis.length === 0) {
605
+ _warn('No editor CLI found — extension not installed automatically');
606
+ _info('Install VS Code/Cursor/Windsurf and add CLI to PATH (or set CLAWS_VSCODE_CLI), then re-run');
607
+ return;
608
+ }
609
+
610
+ if (!fs.existsSync(extDir)) {
611
+ _warn('extension/ not found in source — skipping extension install');
612
+ return;
613
+ }
614
+
615
+ const version = JSON.parse(fs.readFileSync(path.join(extDir, 'package.json'), 'utf8')).version;
616
+ const vsixPath = path.join(os.tmpdir(), `claws-code-${version}.vsix`);
617
+
618
+ // npm install — must include optionals (node-pty lives in optionalDependencies but is required by bundle-native.mjs)
619
+ _info('npm install (with node-pty)...');
620
+ const npmInstall = spawnSync('npm', ['install', '--no-fund', '--no-audit', '--loglevel=error'],
621
+ { cwd: extDir, stdio: 'inherit', shell: isWin });
622
+ if (npmInstall.status !== 0) {
623
+ if (isWin) {
624
+ _warn('npm install failed — node-pty likely needs C++ build tools');
625
+ _info('Install: winget install Microsoft.VisualStudio.BuildTools');
626
+ _info('Then re-run the Claws installer');
627
+ } else {
628
+ _warn('npm install failed — extension not installed');
629
+ }
630
+ return;
631
+ }
632
+
633
+ // Build extension bundle + native node-pty
634
+ _info('npm run build...');
635
+ const npmBuild = spawnSync('npm', ['run', 'build'],
636
+ { cwd: extDir, stdio: 'inherit', shell: isWin });
637
+ if (npmBuild.status !== 0) {
638
+ _warn('Extension build failed — extension not installed');
639
+ return;
640
+ }
641
+
642
+ // W7h-20: Electron ABI drift detection — warn if built binary targets a
643
+ // different Electron than the currently installed editor. Matches install.sh:403-454.
644
+ _checkElectronAbiDrift(extDir);
645
+
646
+ // Package VSIX — flags match install.sh canonical set (line 686)
647
+ _info('vsce package...');
648
+ spawnSync('npx', ['--yes', '@vscode/vsce', 'package',
649
+ '--skip-license', '--no-git-tag-version', '--no-update-package-json',
650
+ '--no-dependencies', '-o', wq(vsixPath)],
651
+ { cwd: extDir, stdio: 'inherit', shell: isWin });
652
+ if (!fs.existsSync(vsixPath)) {
653
+ _warn('VSIX not produced — extension not installed');
654
+ return;
655
+ }
656
+
657
+ // W7h-21: VSIX size check — <50 KB suggests native binary missing from package.
658
+ // Matches install.sh:697-703.
659
+ try {
660
+ const vsixSize = fs.statSync(vsixPath).size;
661
+ if (vsixSize < 50 * 1024) {
662
+ _warn(`VSIX is suspiciously small (${vsixSize} bytes < 50 KB) — native pty.node may be missing`);
663
+ _info('Run: cd extension && npm run build to rebuild with native binary');
664
+ }
665
+ } catch (_) {}
666
+
667
+ // Install extension into each found editor
668
+ let anySucceeded = false;
669
+ for (const { label, cliPath } of editorClis) {
670
+ _info(`installing into ${label}...`);
671
+ // W7h-26: sudo fallback on failure (Linux/macOS only) — matches install.sh:714-719.
672
+ let installOk = false;
673
+ const installR = spawnSync(wq(cliPath), ['--install-extension', wq(vsixPath), '--force'],
674
+ { stdio: 'inherit', shell: isWin });
675
+ if (installR.status === 0) {
676
+ installOk = true;
677
+ } else if (!isWin) {
678
+ _info(` ${label} install failed — retrying with sudo...`);
679
+ const sudoR = spawnSync('sudo', [cliPath, '--install-extension', vsixPath, '--force'],
680
+ { stdio: 'inherit' });
681
+ if (sudoR.status === 0) {
682
+ _info(` installed via sudo (extensions dir required elevated permissions)`);
683
+ installOk = true;
684
+ }
685
+ }
686
+
687
+ if (!installOk) {
688
+ _warn(`Extension install failed for ${label} — VSIX at ${vsixPath}`);
689
+ } else {
690
+ const listR = spawnSync(wq(cliPath), ['--list-extensions'], { encoding: 'utf8', shell: isWin });
691
+ if (listR.stdout && listR.stdout.includes('neunaha.claws')) {
692
+ _ok(`neunaha.claws installed into ${label} (verified)`);
693
+ } else {
694
+ _ok(`Extension installed into ${label}`);
695
+ }
696
+ anySucceeded = true;
697
+
698
+ // W7h-25: Stale extension directory cleanup (matches install.sh:740-760).
699
+ // Remove neunaha.claws-<old-version> dirs after successful install.
700
+ _cleanStaleExtensionDirs(label, version);
701
+ }
702
+ }
703
+
704
+ if (!anySucceeded) {
705
+ _warn('Extension install failed for all editors');
706
+ }
707
+
708
+ // Clean up VSIX from temp dir — matches install.sh /tmp approach
709
+ try { fs.unlinkSync(vsixPath); } catch (_) {}
710
+ }
711
+
712
+ function _writeInstallCommand(projectRoot, dryRun) {
713
+ const cmdDir = path.join(projectRoot, '.claude', 'commands');
714
+ const dest = path.join(cmdDir, 'claws-install.md');
715
+ const content = [
716
+ '---',
717
+ 'name: claws-install',
718
+ 'description: Install or update Claws — Terminal Control Bridge for VS Code.',
719
+ '---',
720
+ '',
721
+ '# /claws-install',
722
+ '',
723
+ 'Install or update Claws in this project:',
724
+ '',
725
+ '```bash',
726
+ 'npx claws-code install',
727
+ '```',
728
+ '',
729
+ 'After the script completes:',
730
+ '1. Reload VS Code: Cmd+Shift+P → Developer: Reload Window',
731
+ '2. Restart Claude Code so the project-local `.mcp.json` is picked up.',
732
+ '3. Try `/claws-help` or `/claws-status`.',
733
+ '',
734
+ 'If MCP tools don\'t appear, run `/claws-fix` or `/claws-report`.',
735
+ '',
736
+ ].join('\n');
737
+ if (dryRun) { dryRunLog(`write ${dest}`); return; }
738
+ fs.mkdirSync(cmdDir, { recursive: true });
739
+ fs.writeFileSync(dest, content, 'utf8');
740
+ }
741
+
742
+ /**
743
+ * Remove stale neunaha.claws-<old-version> dirs from the editor's extensions
744
+ * directory after a successful install (W7h-25). Matches install.sh:740-760.
745
+ * Polls up to 1s for the new version dir to appear (VS Code extracts asynchronously).
746
+ * @param {string} label - editor label: 'code' | 'code-insiders' | 'cursor' | 'windsurf'
747
+ * @param {string} version - current version (the one just installed)
748
+ */
749
+ function _cleanStaleExtensionDirs(label, version) {
750
+ const extDirMap = {
751
+ 'code': path.join(HOME, '.vscode', 'extensions'),
752
+ 'code-insiders': path.join(HOME, '.vscode-insiders', 'extensions'),
753
+ 'cursor': path.join(HOME, '.cursor', 'extensions'),
754
+ 'windsurf': path.join(HOME, '.windsurf', 'extensions'),
755
+ };
756
+ const extDir = extDirMap[label];
757
+ if (!extDir || !fs.existsSync(extDir)) return;
758
+
759
+ const keptDir = path.join(extDir, `neunaha.claws-${version}`);
760
+ // Poll up to 1s (5x200ms) for VS Code to extract the new VSIX before cleanup.
761
+ for (let i = 0; i < 5; i++) {
762
+ if (fs.existsSync(keptDir)) break;
763
+ _sleepSync(200);
764
+ }
765
+
766
+ if (!fs.existsSync(keptDir)) {
767
+ _warn(`${label}: new extension dir not yet present — skipping stale cleanup`);
768
+ _info('(VS Code may still be extracting — stale dirs cleaned on next install)');
769
+ return;
770
+ }
771
+
772
+ let entries;
773
+ try { entries = fs.readdirSync(extDir); } catch (_) { return; }
774
+ for (const entry of entries) {
775
+ if (!entry.startsWith('neunaha.claws-')) continue;
776
+ const fullPath = path.join(extDir, entry);
777
+ if (fullPath === keptDir) continue;
778
+ if (!fs.statSync(fullPath).isDirectory()) continue;
779
+ try {
780
+ fs.rmSync(fullPath, { recursive: true, force: true });
781
+ _info(` removed stale install ${entry}`);
782
+ } catch (_) {
783
+ // best-effort; ignore EPERM (running extension may hold file locks)
784
+ }
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Detect Electron ABI drift: compare the last-built Electron version recorded
790
+ * in extension/native/.metadata.json against the currently installed editor's
791
+ * Electron version. Warns if they differ (matching install.sh:403-454, W7h-20).
792
+ * Silently skips when detection is not possible (metadata or editor not found).
793
+ * @param {string} extDir - absolute path to extension/
794
+ */
795
+ function _checkElectronAbiDrift(extDir) {
796
+ const metaPath = path.join(extDir, 'native', '.metadata.json');
797
+ if (!fs.existsSync(metaPath)) return;
798
+
799
+ let lastElec = '';
800
+ try {
801
+ lastElec = JSON.parse(fs.readFileSync(metaPath, 'utf8')).electronVersion || '';
802
+ } catch (_) { return; }
803
+ if (!lastElec) return;
804
+
805
+ const currElec = _detectCurrentElectronVersion();
806
+ if (!currElec) {
807
+ _warn('Could not detect the current editor Electron version — skipping ABI drift check');
808
+ _info('Set CLAWS_ELECTRON_VERSION=<version> to specify it explicitly');
809
+ return;
810
+ }
811
+
812
+ if (currElec !== lastElec) {
813
+ _warn(`Electron ABI drift detected: built for ${lastElec}, editor is ${currElec}`);
814
+ _warn('Run: cd extension && npm run build to rebuild native pty.node for your editor');
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Detect the current VS Code/Cursor/Windsurf Electron version from the app bundle.
820
+ * macOS: reads Info.plist CFBundleVersion from the Electron Framework.
821
+ * Linux: runs the electron binary with --version.
822
+ * Respects CLAWS_ELECTRON_VERSION env override.
823
+ * @returns {string|null}
824
+ */
825
+ function _detectCurrentElectronVersion() {
826
+ if (process.env.CLAWS_ELECTRON_VERSION) return process.env.CLAWS_ELECTRON_VERSION;
827
+
828
+ const platform = process.platform;
829
+ const term = process.env.TERM_PROGRAM || '';
830
+
831
+ if (platform === 'darwin') {
832
+ const appOrder = (() => {
833
+ if (term === 'cursor') return ['Cursor', 'Visual Studio Code', 'Visual Studio Code - Insiders', 'Windsurf'];
834
+ if (term === 'windsurf') return ['Windsurf', 'Visual Studio Code', 'Visual Studio Code - Insiders', 'Cursor'];
835
+ return ['Visual Studio Code', 'Visual Studio Code - Insiders', 'Cursor', 'Windsurf'];
836
+ })();
837
+ for (const app of appOrder) {
838
+ const plist = `/Applications/${app}.app/Contents/Frameworks/Electron Framework.framework/Resources/Info.plist`;
839
+ if (fs.existsSync(plist)) {
840
+ const r = spawnSync('plutil', ['-extract', 'CFBundleVersion', 'raw', plist],
841
+ { encoding: 'utf8', stdio: 'pipe' });
842
+ if (r.status === 0 && r.stdout && r.stdout.trim()) return r.stdout.trim();
843
+ }
844
+ }
845
+ } else if (platform === 'linux') {
846
+ const epOrder = (() => {
847
+ if (term === 'cursor') return ['/usr/share/cursor/electron', '/opt/cursor/electron', '/usr/share/code/electron', '/usr/share/windsurf/electron'];
848
+ if (term === 'windsurf') return ['/usr/share/windsurf/electron', '/opt/windsurf/electron', '/usr/share/code/electron', '/usr/share/cursor/electron'];
849
+ return ['/usr/share/code/electron', '/usr/lib/code/electron', '/usr/share/cursor/electron', '/usr/share/windsurf/electron'];
850
+ })();
851
+ for (const ep of epOrder) {
852
+ if (fs.existsSync(ep)) {
853
+ const r = spawnSync(ep, ['--version'], { encoding: 'utf8', stdio: 'pipe' });
854
+ if (r.status === 0 && r.stdout) {
855
+ const v = r.stdout.trim().replace(/^v/, '').split('\n')[0];
856
+ if (v) return v;
857
+ }
858
+ }
859
+ }
860
+ }
861
+
862
+ return null;
863
+ }
864
+
865
+ /**
866
+ * Copy extension/ artifacts into .claws-bin/extension/ for visibility (W7h-13).
867
+ * Copies: dist, native, package.json, package-lock.json, README.md, CHANGELOG.md,
868
+ * icon.png, .vscodeignore — matches install.sh lines 975-979.
869
+ * @param {string} projectRoot
870
+ * @param {boolean} [dryRun]
871
+ */
872
+ function _copyExtensionArtifacts(projectRoot, dryRun = false) {
873
+ const srcExtDir = path.join(REPO_ROOT, 'extension');
874
+ const destExtDir = path.join(projectRoot, '.claws-bin', 'extension');
875
+
876
+ const ENTRIES = [
877
+ 'dist', 'native', 'package.json', 'package-lock.json',
878
+ 'README.md', 'CHANGELOG.md', 'icon.png', '.vscodeignore',
879
+ ];
880
+
881
+ if (dryRun) {
882
+ for (const entry of ENTRIES) {
883
+ dryRunLog(`copy extension/${entry} → .claws-bin/extension/${entry}`);
884
+ }
885
+ return;
886
+ }
887
+
888
+ if (!fs.existsSync(srcExtDir)) return;
889
+
890
+ try {
891
+ fs.rmSync(destExtDir, { recursive: true, force: true });
892
+ fs.mkdirSync(destExtDir, { recursive: true });
893
+ } catch (_) {}
894
+
895
+ for (const entry of ENTRIES) {
896
+ const src = path.join(srcExtDir, entry);
897
+ if (!fs.existsSync(src)) continue;
898
+ const dest = path.join(destExtDir, entry);
899
+ try {
900
+ fs.cpSync(src, dest, { recursive: true });
901
+ } catch (_) {}
902
+ }
903
+ _ok('extension/ artifacts copied → .claws-bin/extension/');
904
+ }
905
+
906
+ /**
907
+ * Copy srcDir to destDir atomically via tmp → rename (M-09 pattern).
908
+ * 1. Copy srcDir → destDir.claws-tmp.<pid>
909
+ * 2. Move existing destDir aside → destDir.claws-old.<ts>
910
+ * 3. Rename tmp → destDir
911
+ * 4. Remove the moved-aside old dir (best-effort)
912
+ * If killed before step 3, destDir is left untouched.
913
+ * @param {string} srcDir
914
+ * @param {string} destDir
915
+ */
916
+ function _copyDirAtomic(srcDir, destDir) {
917
+ const ts = Date.now();
918
+ const tmp = destDir + '.claws-tmp.' + process.pid;
919
+ const old = destDir + '.claws-old.' + ts;
920
+
921
+ try {
922
+ if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true });
923
+ _copyDirSync(srcDir, tmp);
924
+ } catch (err) {
925
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
926
+ throw err;
927
+ }
928
+
929
+ const destExists = fs.existsSync(destDir);
930
+ try {
931
+ if (destExists) fs.renameSync(destDir, old);
932
+ fs.renameSync(tmp, destDir);
933
+ } catch (err) {
934
+ if (destExists) {
935
+ try {
936
+ if (!fs.existsSync(destDir)) fs.renameSync(old, destDir);
937
+ } catch (_) {}
938
+ }
939
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
940
+ throw err;
941
+ }
942
+
943
+ if (destExists) {
944
+ try { fs.rmSync(old, { recursive: true, force: true }); } catch (_) {}
945
+ }
946
+ }
947
+
948
+ function _copyDirSync(src, dest) {
949
+ fs.mkdirSync(dest, { recursive: true });
950
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
951
+ const s = path.join(src, entry.name);
952
+ const d = path.join(dest, entry.name);
953
+ if (entry.isDirectory()) _copyDirSync(s, d);
954
+ else fs.copyFileSync(s, d);
955
+ }
956
+ }
957
+
958
+ /**
959
+ * Install dev-discipline hooks for contributor environments (W7h-31).
960
+ * Copies scripts/dev-hooks/*.js into <project>/.claws-bin/dev-hooks/ and
961
+ * runs inject-dev-hooks.js to register them in <project>/.claude/settings.json.
962
+ * Matches install.sh:1301-1332.
963
+ * @param {string} projectRoot
964
+ */
965
+ function _installDevHooks(projectRoot) {
966
+ const srcDir = path.join(REPO_ROOT, 'scripts', 'dev-hooks');
967
+ const destDir = path.join(projectRoot, '.claws-bin', 'dev-hooks');
968
+ const injectScript = path.join(REPO_ROOT, 'scripts', 'inject-dev-hooks.js');
969
+
970
+ if (!fs.existsSync(srcDir)) return;
971
+
972
+ fs.mkdirSync(destDir, { recursive: true });
973
+
974
+ let count = 0;
975
+ for (const f of fs.readdirSync(srcDir)) {
976
+ if (!f.endsWith('.js')) continue;
977
+ fs.copyFileSync(path.join(srcDir, f), path.join(destDir, f));
978
+ count++;
979
+ }
980
+ _ok(`copied ${count} dev-hook scripts → ${destDir}`);
981
+
982
+ if (fs.existsSync(injectScript)) {
983
+ const r = spawnSync(process.execPath, [injectScript, projectRoot],
984
+ { cwd: REPO_ROOT, stdio: 'inherit', encoding: 'utf8' });
985
+ if (r.status !== 0) {
986
+ _warn('inject-dev-hooks.js failed — dev hooks not registered');
987
+ }
988
+ } else {
989
+ _warn('inject-dev-hooks.js not found — dev hooks not registered');
990
+ }
991
+ }
992
+
993
+ function _printBanner() {
994
+ // On Windows, install.ps1 already printed the ASCII banner before delegating to node.
995
+ // Node's Unicode box-drawing chars garble in CP437/CP1252 consoles, so skip here.
996
+ if (process.platform === 'win32') return;
997
+ const B = '\x1b[38;2;200;90;62m';
998
+ const R = '\x1b[0m';
999
+ process.stdout.write('\n');
1000
+ process.stdout.write(` ${B}╔═══════════════════════════════════════════╗${R}\n`);
1001
+ process.stdout.write(` ${B}║${R} ${B}║${R}\n`);
1002
+ process.stdout.write(` ${B}║${R} \x1b[1mCLAWS\x1b[0m Terminal Control Bridge ${B}║${R}\n`);
1003
+ process.stdout.write(` ${B}║${R} \x1b[2mProject-local orchestration setup\x1b[0m ${B}║${R}\n`);
1004
+ process.stdout.write(` ${B}║${R} ${B}║${R}\n`);
1005
+ process.stdout.write(` ${B}╚═══════════════════════════════════════════╝${R}\n`);
1006
+ process.stdout.write('\n');
1007
+ }
1008
+
1009
+ function _printSuccess() {
1010
+ const kbd = process.platform === 'darwin' ? 'Cmd+Shift+P' : 'Ctrl+Shift+P';
1011
+ process.stdout.write('\n \x1b[32m✓ Claws installed successfully\x1b[0m\n\n');
1012
+ process.stdout.write(' \x1b[1mNext steps:\x1b[0m\n');
1013
+ process.stdout.write(` Reload VS Code: ${kbd} → Developer: Reload Window\n`);
1014
+ process.stdout.write(' Then: /claws-help\n');
1015
+ process.stdout.write('\n');
1016
+ }
1017
+
1018
+ module.exports = { run, _injectHooks, installGlobalHooks };