@appkit/llamacpp-cli 1.0.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 (136) hide show
  1. package/.versionrc.json +16 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +474 -0
  4. package/bin/llamacpp +26 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +196 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/commands/delete.d.ts +2 -0
  10. package/dist/commands/delete.d.ts.map +1 -0
  11. package/dist/commands/delete.js +104 -0
  12. package/dist/commands/delete.js.map +1 -0
  13. package/dist/commands/list.d.ts +2 -0
  14. package/dist/commands/list.d.ts.map +1 -0
  15. package/dist/commands/list.js +37 -0
  16. package/dist/commands/list.js.map +1 -0
  17. package/dist/commands/logs.d.ts +8 -0
  18. package/dist/commands/logs.d.ts.map +1 -0
  19. package/dist/commands/logs.js +57 -0
  20. package/dist/commands/logs.js.map +1 -0
  21. package/dist/commands/ps.d.ts +2 -0
  22. package/dist/commands/ps.d.ts.map +1 -0
  23. package/dist/commands/ps.js +72 -0
  24. package/dist/commands/ps.js.map +1 -0
  25. package/dist/commands/pull.d.ts +6 -0
  26. package/dist/commands/pull.d.ts.map +1 -0
  27. package/dist/commands/pull.js +36 -0
  28. package/dist/commands/pull.js.map +1 -0
  29. package/dist/commands/rm.d.ts +2 -0
  30. package/dist/commands/rm.d.ts.map +1 -0
  31. package/dist/commands/rm.js +134 -0
  32. package/dist/commands/rm.js.map +1 -0
  33. package/dist/commands/run.d.ts +2 -0
  34. package/dist/commands/run.d.ts.map +1 -0
  35. package/dist/commands/run.js +198 -0
  36. package/dist/commands/run.js.map +1 -0
  37. package/dist/commands/search.d.ts +7 -0
  38. package/dist/commands/search.d.ts.map +1 -0
  39. package/dist/commands/search.js +93 -0
  40. package/dist/commands/search.js.map +1 -0
  41. package/dist/commands/show.d.ts +6 -0
  42. package/dist/commands/show.d.ts.map +1 -0
  43. package/dist/commands/show.js +196 -0
  44. package/dist/commands/show.js.map +1 -0
  45. package/dist/commands/start.d.ts +9 -0
  46. package/dist/commands/start.d.ts.map +1 -0
  47. package/dist/commands/start.js +150 -0
  48. package/dist/commands/start.js.map +1 -0
  49. package/dist/commands/stop.d.ts +2 -0
  50. package/dist/commands/stop.d.ts.map +1 -0
  51. package/dist/commands/stop.js +39 -0
  52. package/dist/commands/stop.js.map +1 -0
  53. package/dist/lib/config-generator.d.ts +30 -0
  54. package/dist/lib/config-generator.d.ts.map +1 -0
  55. package/dist/lib/config-generator.js +125 -0
  56. package/dist/lib/config-generator.js.map +1 -0
  57. package/dist/lib/launchctl-manager.d.ts +55 -0
  58. package/dist/lib/launchctl-manager.d.ts.map +1 -0
  59. package/dist/lib/launchctl-manager.js +227 -0
  60. package/dist/lib/launchctl-manager.js.map +1 -0
  61. package/dist/lib/model-downloader.d.ts +44 -0
  62. package/dist/lib/model-downloader.d.ts.map +1 -0
  63. package/dist/lib/model-downloader.js +248 -0
  64. package/dist/lib/model-downloader.js.map +1 -0
  65. package/dist/lib/model-scanner.d.ts +31 -0
  66. package/dist/lib/model-scanner.d.ts.map +1 -0
  67. package/dist/lib/model-scanner.js +145 -0
  68. package/dist/lib/model-scanner.js.map +1 -0
  69. package/dist/lib/model-search.d.ts +29 -0
  70. package/dist/lib/model-search.d.ts.map +1 -0
  71. package/dist/lib/model-search.js +131 -0
  72. package/dist/lib/model-search.js.map +1 -0
  73. package/dist/lib/port-manager.d.ts +26 -0
  74. package/dist/lib/port-manager.d.ts.map +1 -0
  75. package/dist/lib/port-manager.js +75 -0
  76. package/dist/lib/port-manager.js.map +1 -0
  77. package/dist/lib/state-manager.d.ts +59 -0
  78. package/dist/lib/state-manager.d.ts.map +1 -0
  79. package/dist/lib/state-manager.js +178 -0
  80. package/dist/lib/state-manager.js.map +1 -0
  81. package/dist/lib/status-checker.d.ts +28 -0
  82. package/dist/lib/status-checker.d.ts.map +1 -0
  83. package/dist/lib/status-checker.js +99 -0
  84. package/dist/lib/status-checker.js.map +1 -0
  85. package/dist/types/global-config.d.ts +16 -0
  86. package/dist/types/global-config.d.ts.map +1 -0
  87. package/dist/types/global-config.js +18 -0
  88. package/dist/types/global-config.js.map +1 -0
  89. package/dist/types/model-info.d.ts +9 -0
  90. package/dist/types/model-info.d.ts.map +1 -0
  91. package/dist/types/model-info.js +3 -0
  92. package/dist/types/model-info.js.map +1 -0
  93. package/dist/types/server-config.d.ts +27 -0
  94. package/dist/types/server-config.d.ts.map +1 -0
  95. package/dist/types/server-config.js +15 -0
  96. package/dist/types/server-config.js.map +1 -0
  97. package/dist/utils/file-utils.d.ts +49 -0
  98. package/dist/utils/file-utils.d.ts.map +1 -0
  99. package/dist/utils/file-utils.js +144 -0
  100. package/dist/utils/file-utils.js.map +1 -0
  101. package/dist/utils/format-utils.d.ts +29 -0
  102. package/dist/utils/format-utils.d.ts.map +1 -0
  103. package/dist/utils/format-utils.js +82 -0
  104. package/dist/utils/format-utils.js.map +1 -0
  105. package/dist/utils/process-utils.d.ts +27 -0
  106. package/dist/utils/process-utils.d.ts.map +1 -0
  107. package/dist/utils/process-utils.js +66 -0
  108. package/dist/utils/process-utils.js.map +1 -0
  109. package/package.json +56 -0
  110. package/src/cli.ts +195 -0
  111. package/src/commands/delete.ts +74 -0
  112. package/src/commands/list.ts +37 -0
  113. package/src/commands/logs.ts +61 -0
  114. package/src/commands/ps.ts +79 -0
  115. package/src/commands/pull.ts +40 -0
  116. package/src/commands/rm.ts +114 -0
  117. package/src/commands/run.ts +209 -0
  118. package/src/commands/search.ts +107 -0
  119. package/src/commands/show.ts +207 -0
  120. package/src/commands/start.ts +140 -0
  121. package/src/commands/stop.ts +39 -0
  122. package/src/lib/config-generator.ts +119 -0
  123. package/src/lib/launchctl-manager.ts +209 -0
  124. package/src/lib/model-downloader.ts +259 -0
  125. package/src/lib/model-scanner.ts +125 -0
  126. package/src/lib/model-search.ts +114 -0
  127. package/src/lib/port-manager.ts +80 -0
  128. package/src/lib/state-manager.ts +177 -0
  129. package/src/lib/status-checker.ts +113 -0
  130. package/src/types/global-config.ts +26 -0
  131. package/src/types/model-info.ts +8 -0
  132. package/src/types/server-config.ts +42 -0
  133. package/src/utils/file-utils.ts +106 -0
  134. package/src/utils/format-utils.ts +80 -0
  135. package/src/utils/process-utils.ts +60 -0
  136. package/tsconfig.json +20 -0
@@ -0,0 +1,140 @@
1
+ import chalk from 'chalk';
2
+ import * as path from 'path';
3
+ import { modelScanner } from '../lib/model-scanner';
4
+ import { stateManager } from '../lib/state-manager';
5
+ import { configGenerator, ServerOptions } from '../lib/config-generator';
6
+ import { portManager } from '../lib/port-manager';
7
+ import { launchctlManager } from '../lib/launchctl-manager';
8
+ import { statusChecker } from '../lib/status-checker';
9
+ import { commandExists } from '../utils/process-utils';
10
+ import { formatBytes } from '../utils/format-utils';
11
+ import { ensureDir } from '../utils/file-utils';
12
+
13
+ interface StartOptions {
14
+ port?: number;
15
+ threads?: number;
16
+ ctxSize?: number;
17
+ gpuLayers?: number;
18
+ }
19
+
20
+ export async function startCommand(model: string, options: StartOptions): Promise<void> {
21
+ // Initialize state manager
22
+ await stateManager.initialize();
23
+
24
+ // 1. Check if llama-server exists
25
+ if (!(await commandExists('llama-server'))) {
26
+ throw new Error('llama-server not found. Install with: brew install llama.cpp');
27
+ }
28
+
29
+ // 2. Resolve model path
30
+ const modelPath = await modelScanner.resolveModelPath(model);
31
+ if (!modelPath) {
32
+ throw new Error(`Model not found: ${model}\n\nRun: llamacpp list`);
33
+ }
34
+
35
+ const modelName = path.basename(modelPath);
36
+
37
+ // 3. Check if server already exists for this model
38
+ const existingServer = await stateManager.serverExistsForModel(modelPath);
39
+ if (existingServer) {
40
+ throw new Error(`Server already exists for ${modelName}\n\nUse: llamacpp ps`);
41
+ }
42
+
43
+ // 4. Get model size
44
+ const modelSize = await modelScanner.getModelSize(modelName);
45
+ if (!modelSize) {
46
+ throw new Error(`Failed to read model file: ${modelPath}`);
47
+ }
48
+
49
+ // 5. Determine port
50
+ let port: number;
51
+ if (options.port) {
52
+ portManager.validatePort(options.port);
53
+ const available = await portManager.isPortAvailable(options.port);
54
+ if (!available) {
55
+ throw new Error(`Port ${options.port} is already in use`);
56
+ }
57
+ port = options.port;
58
+ } else {
59
+ port = await portManager.findAvailablePort();
60
+ }
61
+
62
+ // 6. Generate server configuration
63
+ console.log(chalk.blue(`🚀 Starting server for ${modelName}\n`));
64
+
65
+ const serverOptions: ServerOptions = {
66
+ port: options.port,
67
+ threads: options.threads,
68
+ ctxSize: options.ctxSize,
69
+ gpuLayers: options.gpuLayers,
70
+ };
71
+
72
+ const config = await configGenerator.generateConfig(
73
+ modelPath,
74
+ modelName,
75
+ modelSize,
76
+ port,
77
+ serverOptions
78
+ );
79
+
80
+ // Display configuration
81
+ console.log(chalk.dim(`Model: ${modelPath}`));
82
+ console.log(chalk.dim(`Size: ${formatBytes(modelSize)}`));
83
+ console.log(chalk.dim(`Port: ${config.port}${options.port ? '' : ' (auto-assigned)'}`));
84
+ console.log(chalk.dim(`Threads: ${config.threads}`));
85
+ console.log(chalk.dim(`Context Size: ${config.ctxSize}`));
86
+ console.log(chalk.dim(`GPU Layers: ${config.gpuLayers}`));
87
+ console.log();
88
+
89
+ // 7. Ensure log directory exists
90
+ await ensureDir(path.dirname(config.stdoutPath));
91
+
92
+ // 8. Create plist file
93
+ console.log(chalk.dim('Creating launchctl service...'));
94
+ await launchctlManager.createPlist(config);
95
+
96
+ // 9. Load service
97
+ try {
98
+ await launchctlManager.loadService(config.plistPath);
99
+ } catch (error) {
100
+ // Clean up plist if load fails
101
+ await launchctlManager.deletePlist(config.plistPath);
102
+ throw new Error(`Failed to load service: ${(error as Error).message}`);
103
+ }
104
+
105
+ // 10. Start service
106
+ try {
107
+ await launchctlManager.startService(config.label);
108
+ } catch (error) {
109
+ // Clean up if start fails
110
+ await launchctlManager.unloadService(config.plistPath);
111
+ await launchctlManager.deletePlist(config.plistPath);
112
+ throw new Error(`Failed to start service: ${(error as Error).message}`);
113
+ }
114
+
115
+ // 11. Wait for startup
116
+ console.log(chalk.dim('Waiting for server to start...'));
117
+ const started = await launchctlManager.waitForServiceStart(config.label, 5000);
118
+
119
+ if (!started) {
120
+ // Clean up if startup fails
121
+ await launchctlManager.stopService(config.label);
122
+ await launchctlManager.unloadService(config.plistPath);
123
+ await launchctlManager.deletePlist(config.plistPath);
124
+ throw new Error('Server failed to start. Check logs with: llamacpp logs --errors');
125
+ }
126
+
127
+ // 12. Update config with running status
128
+ const updatedConfig = await statusChecker.updateServerStatus(config);
129
+
130
+ // 13. Save server config
131
+ await stateManager.saveServerConfig(updatedConfig);
132
+
133
+ // 14. Display success message
134
+ console.log();
135
+ console.log(chalk.green('✅ Server started successfully!'));
136
+ console.log();
137
+ console.log(chalk.dim(`Connect: http://localhost:${config.port}`));
138
+ console.log(chalk.dim(`View logs: llamacpp logs ${config.id}`));
139
+ console.log(chalk.dim(`Stop: llamacpp stop ${config.id}`));
140
+ }
@@ -0,0 +1,39 @@
1
+ import chalk from 'chalk';
2
+ import { stateManager } from '../lib/state-manager';
3
+ import { launchctlManager } from '../lib/launchctl-manager';
4
+ import { statusChecker } from '../lib/status-checker';
5
+
6
+ export async function stopCommand(identifier: string): Promise<void> {
7
+ // Find server
8
+ const server = await stateManager.findServer(identifier);
9
+ if (!server) {
10
+ throw new Error(`Server not found: ${identifier}\n\nUse: llamacpp ps`);
11
+ }
12
+
13
+ // Check if already stopped
14
+ if (server.status === 'stopped') {
15
+ console.log(chalk.yellow(`⚠️ Server ${server.modelName} is already stopped`));
16
+ return;
17
+ }
18
+
19
+ console.log(chalk.blue(`⏹️ Stopping ${server.modelName} (port ${server.port})...`));
20
+
21
+ // Stop the service
22
+ try {
23
+ await launchctlManager.stopService(server.label);
24
+ } catch (error) {
25
+ throw new Error(`Failed to stop service: ${(error as Error).message}`);
26
+ }
27
+
28
+ // Wait for clean shutdown
29
+ const stopped = await launchctlManager.waitForServiceStop(server.label, 5000);
30
+
31
+ if (!stopped) {
32
+ console.log(chalk.yellow('⚠️ Server did not stop cleanly (timeout)'));
33
+ }
34
+
35
+ // Update server status
36
+ await statusChecker.updateServerStatus(server);
37
+
38
+ console.log(chalk.green('✅ Server stopped'));
39
+ }
@@ -0,0 +1,119 @@
1
+ import * as os from 'os';
2
+ import * as path from 'path';
3
+ import { ServerConfig, sanitizeModelName } from '../types/server-config';
4
+ import { getLogsDir, getLaunchAgentsDir } from '../utils/file-utils';
5
+ import { stateManager } from './state-manager';
6
+
7
+ export interface ServerOptions {
8
+ port?: number;
9
+ threads?: number;
10
+ ctxSize?: number;
11
+ gpuLayers?: number;
12
+ embeddings?: boolean;
13
+ jinja?: boolean;
14
+ }
15
+
16
+ export interface SmartDefaults {
17
+ threads: number;
18
+ ctxSize: number;
19
+ gpuLayers: number;
20
+ }
21
+
22
+ export class ConfigGenerator {
23
+ /**
24
+ * Calculate smart defaults based on model size
25
+ */
26
+ calculateSmartDefaults(modelSizeBytes: number): SmartDefaults {
27
+ const sizeGB = modelSizeBytes / (1024 ** 3);
28
+
29
+ // Context size based on model size
30
+ let ctxSize: number;
31
+ if (sizeGB < 1) {
32
+ ctxSize = 2048; // < 1GB: small context
33
+ } else if (sizeGB < 3) {
34
+ ctxSize = 4096; // 1-3GB: medium
35
+ } else if (sizeGB < 6) {
36
+ ctxSize = 8192; // 3-6GB: large
37
+ } else {
38
+ ctxSize = 16384; // 6GB+: very large
39
+ }
40
+
41
+ // GPU layers - always max for Metal (macOS)
42
+ const gpuLayers = 60; // llama.cpp auto-detects optimal value
43
+
44
+ // Threads - use half of available cores (better performance)
45
+ const cpuCount = os.cpus().length;
46
+ const threads = Math.max(4, Math.floor(cpuCount / 2));
47
+
48
+ return { threads, ctxSize, gpuLayers };
49
+ }
50
+
51
+ /**
52
+ * Generate server configuration
53
+ */
54
+ async generateConfig(
55
+ modelPath: string,
56
+ modelName: string,
57
+ modelSize: number,
58
+ port: number,
59
+ options?: ServerOptions
60
+ ): Promise<ServerConfig> {
61
+ // Calculate smart defaults
62
+ const smartDefaults = this.calculateSmartDefaults(modelSize);
63
+
64
+ // Apply user overrides
65
+ const threads = options?.threads ?? smartDefaults.threads;
66
+ const ctxSize = options?.ctxSize ?? smartDefaults.ctxSize;
67
+ const gpuLayers = options?.gpuLayers ?? smartDefaults.gpuLayers;
68
+ const embeddings = options?.embeddings ?? true;
69
+ const jinja = options?.jinja ?? true;
70
+
71
+ // Generate server ID
72
+ const id = sanitizeModelName(modelName);
73
+
74
+ // Generate paths
75
+ const label = `com.llama.${id}`;
76
+ const plistPath = path.join(getLaunchAgentsDir(), `${label}.plist`);
77
+ const logsDir = getLogsDir();
78
+ const stdoutPath = path.join(logsDir, `${id}.stdout`);
79
+ const stderrPath = path.join(logsDir, `${id}.stderr`);
80
+
81
+ const config: ServerConfig = {
82
+ id,
83
+ modelPath,
84
+ modelName,
85
+ port,
86
+ threads,
87
+ ctxSize,
88
+ gpuLayers,
89
+ embeddings,
90
+ jinja,
91
+ status: 'stopped',
92
+ createdAt: new Date().toISOString(),
93
+ plistPath,
94
+ label,
95
+ stdoutPath,
96
+ stderrPath,
97
+ };
98
+
99
+ return config;
100
+ }
101
+
102
+ /**
103
+ * Merge global defaults with user options
104
+ */
105
+ async mergeWithGlobalDefaults(options?: ServerOptions): Promise<Partial<ServerOptions>> {
106
+ const globalConfig = await stateManager.loadGlobalConfig();
107
+
108
+ return {
109
+ threads: options?.threads ?? globalConfig.defaults.threads,
110
+ ctxSize: options?.ctxSize ?? globalConfig.defaults.ctxSize,
111
+ gpuLayers: options?.gpuLayers ?? globalConfig.defaults.gpuLayers,
112
+ embeddings: options?.embeddings ?? true,
113
+ jinja: options?.jinja ?? true,
114
+ };
115
+ }
116
+ }
117
+
118
+ // Export singleton instance
119
+ export const configGenerator = new ConfigGenerator();
@@ -0,0 +1,209 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs/promises';
3
+ import { ServerConfig } from '../types/server-config';
4
+ import { execCommand, execAsync } from '../utils/process-utils';
5
+ import { writeFileAtomic, fileExists } from '../utils/file-utils';
6
+
7
+ export interface ServiceStatus {
8
+ isRunning: boolean;
9
+ pid: number | null;
10
+ exitCode: number | null;
11
+ lastExitReason?: string;
12
+ }
13
+
14
+ export class LaunchctlManager {
15
+ /**
16
+ * Generate plist XML content for a server
17
+ */
18
+ generatePlist(config: ServerConfig): string {
19
+ return `<?xml version="1.0" encoding="UTF-8"?>
20
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
21
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
22
+ <plist version="1.0">
23
+ <dict>
24
+ <key>Label</key>
25
+ <string>${config.label}</string>
26
+
27
+ <key>ProgramArguments</key>
28
+ <array>
29
+ <string>/opt/homebrew/bin/llama-server</string>
30
+ <string>--model</string>
31
+ <string>${config.modelPath}</string>
32
+ <string>--port</string>
33
+ <string>${config.port}</string>
34
+ <string>--threads</string>
35
+ <string>${config.threads}</string>
36
+ <string>--ctx-size</string>
37
+ <string>${config.ctxSize}</string>
38
+ <string>--gpu-layers</string>
39
+ <string>${config.gpuLayers}</string>
40
+ <string>--embeddings</string>
41
+ <string>--jinja</string>
42
+ </array>
43
+
44
+ <key>RunAtLoad</key>
45
+ <false/>
46
+
47
+ <key>KeepAlive</key>
48
+ <dict>
49
+ <key>Crashed</key>
50
+ <true/>
51
+ <key>SuccessfulExit</key>
52
+ <false/>
53
+ </dict>
54
+
55
+ <key>StandardOutPath</key>
56
+ <string>${config.stdoutPath}</string>
57
+
58
+ <key>StandardErrorPath</key>
59
+ <string>${config.stderrPath}</string>
60
+
61
+ <key>WorkingDirectory</key>
62
+ <string>/tmp</string>
63
+
64
+ <key>ThrottleInterval</key>
65
+ <integer>10</integer>
66
+ </dict>
67
+ </plist>
68
+ `;
69
+ }
70
+
71
+ /**
72
+ * Create and write plist file
73
+ */
74
+ async createPlist(config: ServerConfig): Promise<void> {
75
+ const plistContent = this.generatePlist(config);
76
+ await writeFileAtomic(config.plistPath, plistContent);
77
+ }
78
+
79
+ /**
80
+ * Delete plist file
81
+ */
82
+ async deletePlist(plistPath: string): Promise<void> {
83
+ if (await fileExists(plistPath)) {
84
+ await fs.unlink(plistPath);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Load service (register with launchctl)
90
+ */
91
+ async loadService(plistPath: string): Promise<void> {
92
+ await execCommand(`launchctl load "${plistPath}"`);
93
+ }
94
+
95
+ /**
96
+ * Unload service (unregister from launchctl)
97
+ */
98
+ async unloadService(plistPath: string): Promise<void> {
99
+ try {
100
+ await execCommand(`launchctl unload "${plistPath}"`);
101
+ } catch (error) {
102
+ // Ignore errors if service is not loaded
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Start service
108
+ */
109
+ async startService(label: string): Promise<void> {
110
+ await execCommand(`launchctl start ${label}`);
111
+ }
112
+
113
+ /**
114
+ * Stop service
115
+ */
116
+ async stopService(label: string): Promise<void> {
117
+ await execCommand(`launchctl stop ${label}`);
118
+ }
119
+
120
+ /**
121
+ * Get service status from launchctl
122
+ */
123
+ async getServiceStatus(label: string): Promise<ServiceStatus> {
124
+ try {
125
+ const { stdout } = await execAsync(`launchctl list | grep ${label}`);
126
+ const lines = stdout.trim().split('\n');
127
+
128
+ for (const line of lines) {
129
+ const parts = line.split(/\s+/);
130
+ if (parts.length >= 3) {
131
+ const pidStr = parts[0].trim();
132
+ const exitCodeStr = parts[1].trim();
133
+ const serviceLabel = parts[2].trim();
134
+
135
+ // Match the exact label
136
+ if (serviceLabel === label) {
137
+ const pid = pidStr !== '-' ? parseInt(pidStr, 10) : null;
138
+ const exitCode = exitCodeStr !== '-' ? parseInt(exitCodeStr, 10) : null;
139
+ const isRunning = pid !== null;
140
+
141
+ return {
142
+ isRunning,
143
+ pid,
144
+ exitCode,
145
+ lastExitReason: this.interpretExitCode(exitCode),
146
+ };
147
+ }
148
+ }
149
+ }
150
+
151
+ // Service not found
152
+ return {
153
+ isRunning: false,
154
+ pid: null,
155
+ exitCode: null,
156
+ };
157
+ } catch (error) {
158
+ // Service not found or not loaded
159
+ return {
160
+ isRunning: false,
161
+ pid: null,
162
+ exitCode: null,
163
+ };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Interpret exit code to human-readable reason
169
+ */
170
+ private interpretExitCode(code: number | null): string | undefined {
171
+ if (code === null || code === 0) return undefined;
172
+ if (code === -9) return 'Force killed (SIGKILL)';
173
+ if (code === -15) return 'Terminated (SIGTERM)';
174
+ return `Exit code: ${code}`;
175
+ }
176
+
177
+ /**
178
+ * Wait for service to start (with timeout)
179
+ */
180
+ async waitForServiceStart(label: string, timeoutMs = 5000): Promise<boolean> {
181
+ const startTime = Date.now();
182
+ while (Date.now() - startTime < timeoutMs) {
183
+ const status = await this.getServiceStatus(label);
184
+ if (status.isRunning) {
185
+ return true;
186
+ }
187
+ await new Promise((resolve) => setTimeout(resolve, 500));
188
+ }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Wait for service to stop (with timeout)
194
+ */
195
+ async waitForServiceStop(label: string, timeoutMs = 5000): Promise<boolean> {
196
+ const startTime = Date.now();
197
+ while (Date.now() - startTime < timeoutMs) {
198
+ const status = await this.getServiceStatus(label);
199
+ if (!status.isRunning) {
200
+ return true;
201
+ }
202
+ await new Promise((resolve) => setTimeout(resolve, 500));
203
+ }
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // Export singleton instance
209
+ export const launchctlManager = new LaunchctlManager();