happy-coder 0.11.2 → 0.12.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.
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Shared utilities for finding and resolving Claude Code CLI path
3
+ * Used by both local and remote launchers
4
+ *
5
+ * Supports multiple installation methods:
6
+ * 1. npm global: npm install -g @anthropic-ai/claude-code
7
+ * 2. Homebrew: brew install claude-code
8
+ * 3. Native installer:
9
+ * - macOS/Linux: curl -fsSL https://claude.ai/install.sh | bash
10
+ * - PowerShell: irm https://claude.ai/install.ps1 | iex
11
+ * - Windows CMD: curl -fsSL https://claude.ai/install.cmd | cmd
12
+ */
13
+
14
+ const { execSync } = require('child_process');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+
19
+ /**
20
+ * Safely resolve symlink or return path if it exists
21
+ * @param {string} filePath - Path to resolve
22
+ * @returns {string|null} Resolved path or null if not found
23
+ */
24
+ function resolvePathSafe(filePath) {
25
+ if (!fs.existsSync(filePath)) return null;
26
+ try {
27
+ return fs.realpathSync(filePath);
28
+ } catch (e) {
29
+ // Symlink resolution failed, return original path
30
+ return filePath;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Find path to npm globally installed Claude Code CLI
36
+ * @returns {string|null} Path to cli.js or null if not found
37
+ */
38
+ function findNpmGlobalCliPath() {
39
+ try {
40
+ const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim();
41
+ const globalCliPath = path.join(globalRoot, '@anthropic-ai', 'claude-code', 'cli.js');
42
+ if (fs.existsSync(globalCliPath)) {
43
+ return globalCliPath;
44
+ }
45
+ } catch (e) {
46
+ // npm root -g failed
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Find path to Homebrew installed Claude Code CLI
53
+ * @returns {string|null} Path to cli.js or binary, or null if not found
54
+ */
55
+ function findHomebrewCliPath() {
56
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
57
+ return null;
58
+ }
59
+
60
+ // Try to get Homebrew prefix via command first
61
+ let brewPrefix = null;
62
+ try {
63
+ brewPrefix = execSync('brew --prefix 2>/dev/null', { encoding: 'utf8' }).trim();
64
+ } catch (e) {
65
+ // brew command not in PATH, try standard locations
66
+ }
67
+
68
+ // Standard Homebrew locations to check
69
+ const possiblePrefixes = [];
70
+ if (brewPrefix) {
71
+ possiblePrefixes.push(brewPrefix);
72
+ }
73
+
74
+ // Add standard locations based on platform
75
+ if (process.platform === 'darwin') {
76
+ // macOS: Intel (/usr/local) or Apple Silicon (/opt/homebrew)
77
+ possiblePrefixes.push('/opt/homebrew', '/usr/local');
78
+ } else if (process.platform === 'linux') {
79
+ // Linux: system-wide or user installation
80
+ const homeDir = os.homedir();
81
+ possiblePrefixes.push('/home/linuxbrew/.linuxbrew', path.join(homeDir, '.linuxbrew'));
82
+ }
83
+
84
+ // Check each possible prefix
85
+ for (const prefix of possiblePrefixes) {
86
+ if (!fs.existsSync(prefix)) {
87
+ continue;
88
+ }
89
+
90
+ // Homebrew installs claude-code as a Cask (binary) in Caskroom
91
+ const caskroomPath = path.join(prefix, 'Caskroom', 'claude-code');
92
+ if (fs.existsSync(caskroomPath)) {
93
+ const found = findLatestVersionBinary(caskroomPath, 'claude');
94
+ if (found) return found;
95
+ }
96
+
97
+ // Also check Cellar (for formula installations, though claude-code is usually a Cask)
98
+ const cellarPath = path.join(prefix, 'Cellar', 'claude-code');
99
+ if (fs.existsSync(cellarPath)) {
100
+ // Cellar has different structure - check for cli.js in libexec
101
+ const entries = fs.readdirSync(cellarPath);
102
+ if (entries.length > 0) {
103
+ const sorted = entries.sort((a, b) => compareVersions(b, a));
104
+ const latestVersion = sorted[0];
105
+ const cliPath = path.join(cellarPath, latestVersion, 'libexec', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
106
+ if (fs.existsSync(cliPath)) {
107
+ return cliPath;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Check bin directory for symlink (most reliable)
113
+ const binPath = path.join(prefix, 'bin', 'claude');
114
+ const resolvedBinPath = resolvePathSafe(binPath);
115
+ if (resolvedBinPath) return resolvedBinPath;
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Find path to native installer Claude Code CLI
123
+ *
124
+ * Installation locations:
125
+ * - macOS/Linux: ~/.local/bin/claude (symlink) -> ~/.local/share/claude/versions/<version>
126
+ * - Windows: %LOCALAPPDATA%\Claude\ or %USERPROFILE%\.claude\
127
+ * - Legacy: ~/.claude/local/
128
+ *
129
+ * @returns {string|null} Path to cli.js or binary, or null if not found
130
+ */
131
+ function findNativeInstallerCliPath() {
132
+ const homeDir = os.homedir();
133
+
134
+ // Windows-specific locations
135
+ if (process.platform === 'win32') {
136
+ const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
137
+
138
+ // Check %LOCALAPPDATA%\Claude\
139
+ const windowsClaudePath = path.join(localAppData, 'Claude');
140
+ if (fs.existsSync(windowsClaudePath)) {
141
+ // Check for versions directory
142
+ const versionsDir = path.join(windowsClaudePath, 'versions');
143
+ if (fs.existsSync(versionsDir)) {
144
+ const found = findLatestVersionBinary(versionsDir);
145
+ if (found) return found;
146
+ }
147
+
148
+ // Check for claude.exe directly
149
+ const exePath = path.join(windowsClaudePath, 'claude.exe');
150
+ if (fs.existsSync(exePath)) {
151
+ return exePath;
152
+ }
153
+
154
+ // Check for cli.js
155
+ const cliPath = path.join(windowsClaudePath, 'cli.js');
156
+ if (fs.existsSync(cliPath)) {
157
+ return cliPath;
158
+ }
159
+ }
160
+
161
+ // Check %USERPROFILE%\.claude\ (alternative Windows location)
162
+ const dotClaudePath = path.join(homeDir, '.claude');
163
+ if (fs.existsSync(dotClaudePath)) {
164
+ const versionsDir = path.join(dotClaudePath, 'versions');
165
+ if (fs.existsSync(versionsDir)) {
166
+ const found = findLatestVersionBinary(versionsDir);
167
+ if (found) return found;
168
+ }
169
+
170
+ const exePath = path.join(dotClaudePath, 'claude.exe');
171
+ if (fs.existsSync(exePath)) {
172
+ return exePath;
173
+ }
174
+ }
175
+ }
176
+
177
+ // Check ~/.local/bin/claude symlink (most common location on macOS/Linux)
178
+ const localBinPath = path.join(homeDir, '.local', 'bin', 'claude');
179
+ const resolvedLocalBinPath = resolvePathSafe(localBinPath);
180
+ if (resolvedLocalBinPath) return resolvedLocalBinPath;
181
+
182
+ // Check ~/.local/share/claude/versions/ (native installer location)
183
+ const versionsDir = path.join(homeDir, '.local', 'share', 'claude', 'versions');
184
+ if (fs.existsSync(versionsDir)) {
185
+ const found = findLatestVersionBinary(versionsDir);
186
+ if (found) return found;
187
+ }
188
+
189
+ // Check ~/.claude/local/ (older installation method)
190
+ const nativeBasePath = path.join(homeDir, '.claude', 'local');
191
+ if (fs.existsSync(nativeBasePath)) {
192
+ // Look for the cli.js in the node_modules structure
193
+ const cliPath = path.join(nativeBasePath, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
194
+ if (fs.existsSync(cliPath)) {
195
+ return cliPath;
196
+ }
197
+
198
+ // Alternative: direct cli.js in the installation
199
+ const directCliPath = path.join(nativeBasePath, 'cli.js');
200
+ if (fs.existsSync(directCliPath)) {
201
+ return directCliPath;
202
+ }
203
+ }
204
+
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Helper to find the latest version binary in a versions directory
210
+ * @param {string} versionsDir - Path to versions directory
211
+ * @param {string} [binaryName] - Optional binary name to look for inside version directory
212
+ * @returns {string|null} Path to binary or null
213
+ */
214
+ function findLatestVersionBinary(versionsDir, binaryName = null) {
215
+ try {
216
+ const entries = fs.readdirSync(versionsDir);
217
+ if (entries.length === 0) return null;
218
+
219
+ // Sort using semver comparison (descending)
220
+ const sorted = entries.sort((a, b) => compareVersions(b, a));
221
+ const latestVersion = sorted[0];
222
+ const versionPath = path.join(versionsDir, latestVersion);
223
+
224
+ // Check if it's a file (binary) or directory
225
+ const stat = fs.statSync(versionPath);
226
+ if (stat.isFile()) {
227
+ return versionPath;
228
+ } else if (stat.isDirectory()) {
229
+ // If specific binary name provided, check for it
230
+ if (binaryName) {
231
+ const binaryPath = path.join(versionPath, binaryName);
232
+ if (fs.existsSync(binaryPath)) {
233
+ return binaryPath;
234
+ }
235
+ }
236
+ // Check for executable or cli.js inside directory
237
+ const exePath = path.join(versionPath, process.platform === 'win32' ? 'claude.exe' : 'claude');
238
+ if (fs.existsSync(exePath)) {
239
+ return exePath;
240
+ }
241
+ const cliPath = path.join(versionPath, 'cli.js');
242
+ if (fs.existsSync(cliPath)) {
243
+ return cliPath;
244
+ }
245
+ }
246
+ } catch (e) {
247
+ // Directory read failed
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Find path to globally installed Claude Code CLI
254
+ * Checks multiple installation methods in order of preference:
255
+ * 1. npm global (highest priority)
256
+ * 2. Homebrew
257
+ * 3. Native installer
258
+ * @returns {{path: string, source: string}|null} Path and source, or null if not found
259
+ */
260
+ function findGlobalClaudeCliPath() {
261
+ // Check npm global first (highest priority)
262
+ const npmPath = findNpmGlobalCliPath();
263
+ if (npmPath) return { path: npmPath, source: 'npm' };
264
+
265
+ // Check Homebrew installation
266
+ const homebrewPath = findHomebrewCliPath();
267
+ if (homebrewPath) return { path: homebrewPath, source: 'Homebrew' };
268
+
269
+ // Check native installer
270
+ const nativePath = findNativeInstallerCliPath();
271
+ if (nativePath) return { path: nativePath, source: 'native installer' };
272
+
273
+ return null;
274
+ }
275
+
276
+ /**
277
+ * Get version from Claude Code package.json
278
+ * @param {string} cliPath - Path to cli.js
279
+ * @returns {string|null} Version string or null
280
+ */
281
+ function getVersion(cliPath) {
282
+ try {
283
+ const pkgPath = path.join(path.dirname(cliPath), 'package.json');
284
+ if (fs.existsSync(pkgPath)) {
285
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
286
+ return pkg.version;
287
+ }
288
+ } catch (e) {}
289
+ return null;
290
+ }
291
+
292
+ /**
293
+ * Compare semver versions
294
+ * @param {string} a - First version
295
+ * @param {string} b - Second version
296
+ * @returns {number} 1 if a > b, -1 if a < b, 0 if equal
297
+ */
298
+ function compareVersions(a, b) {
299
+ if (!a || !b) return 0;
300
+ const partsA = a.split('.').map(Number);
301
+ const partsB = b.split('.').map(Number);
302
+ for (let i = 0; i < 3; i++) {
303
+ if (partsA[i] > partsB[i]) return 1;
304
+ if (partsA[i] < partsB[i]) return -1;
305
+ }
306
+ return 0;
307
+ }
308
+
309
+ /**
310
+ * Get the CLI path to use (global installation)
311
+ * @returns {string} Path to cli.js
312
+ * @throws {Error} If no global installation found
313
+ */
314
+ function getClaudeCliPath() {
315
+ const result = findGlobalClaudeCliPath();
316
+ if (!result) {
317
+ console.error('\n\x1b[1m\x1b[33mClaude Code is not installed\x1b[0m\n');
318
+ console.error('Please install Claude Code using one of these methods:\n');
319
+ console.error('\x1b[1mOption 1 - npm (recommended, highest priority):\x1b[0m');
320
+ console.error(' \x1b[36mnpm install -g @anthropic-ai/claude-code\x1b[0m\n');
321
+ console.error('\x1b[1mOption 2 - Homebrew (macOS/Linux):\x1b[0m');
322
+ console.error(' \x1b[36mbrew install claude-code\x1b[0m\n');
323
+ console.error('\x1b[1mOption 3 - Native installer:\x1b[0m');
324
+ console.error(' \x1b[90mmacOS/Linux:\x1b[0m \x1b[36mcurl -fsSL https://claude.ai/install.sh | bash\x1b[0m');
325
+ console.error(' \x1b[90mPowerShell:\x1b[0m \x1b[36mirm https://claude.ai/install.ps1 | iex\x1b[0m');
326
+ console.error(' \x1b[90mWindows CMD:\x1b[0m \x1b[36mcurl -fsSL https://claude.ai/install.cmd -o install.cmd && install.cmd && del install.cmd\x1b[0m\n');
327
+ console.error('\x1b[90mNote: If multiple installations exist, npm takes priority.\x1b[0m\n');
328
+ process.exit(1);
329
+ }
330
+
331
+ const version = getVersion(result.path);
332
+ const versionStr = version ? ` v${version}` : '';
333
+ console.error(`\x1b[90mUsing Claude Code${versionStr} from ${result.source}\x1b[0m`);
334
+
335
+ return result.path;
336
+ }
337
+
338
+ /**
339
+ * Run Claude CLI, handling both JavaScript and binary files
340
+ * @param {string} cliPath - Path to CLI (from getClaudeCliPath)
341
+ */
342
+ function runClaudeCli(cliPath) {
343
+ const { pathToFileURL } = require('url');
344
+ const { spawn } = require('child_process');
345
+
346
+ // Check if it's a JavaScript file (.js or .cjs) or a binary file
347
+ const isJsFile = cliPath.endsWith('.js') || cliPath.endsWith('.cjs');
348
+
349
+ if (isJsFile) {
350
+ // JavaScript file - use import to keep interceptors working
351
+ const importUrl = pathToFileURL(cliPath).href;
352
+ import(importUrl);
353
+ } else {
354
+ // Binary file (e.g., Homebrew installation) - spawn directly
355
+ // Note: Interceptors won't work with binary files, but that's acceptable
356
+ // as binary files are self-contained and don't need interception
357
+ const args = process.argv.slice(2);
358
+ const child = spawn(cliPath, args, {
359
+ stdio: 'inherit',
360
+ env: process.env
361
+ });
362
+ child.on('exit', (code) => {
363
+ process.exit(code || 0);
364
+ });
365
+ }
366
+ }
367
+
368
+ module.exports = {
369
+ findGlobalClaudeCliPath,
370
+ findNpmGlobalCliPath,
371
+ findHomebrewCliPath,
372
+ findNativeInstallerCliPath,
373
+ getVersion,
374
+ compareVersions,
375
+ getClaudeCliPath,
376
+ runClaudeCli
377
+ };
378
+