@scheduler-systems/gal-run 0.0.290 → 0.0.292

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/dist/index.cjs CHANGED
@@ -3970,7 +3970,7 @@ var cliVersion, defaultApiUrl, BUILD_CONSTANTS, constants_default;
3970
3970
  var init_constants = __esm({
3971
3971
  "src/constants.ts"() {
3972
3972
  "use strict";
3973
- cliVersion = true ? "0.0.290" : "0.0.0-dev";
3973
+ cliVersion = true ? "0.0.292" : "0.0.0-dev";
3974
3974
  defaultApiUrl = true ? "https://api.gal.run" : "http://localhost:3000";
3975
3975
  BUILD_CONSTANTS = Object.freeze([cliVersion, defaultApiUrl]);
3976
3976
  constants_default = BUILD_CONSTANTS;
@@ -4437,6 +4437,37 @@ var init_path_conflict = __esm({
4437
4437
  }
4438
4438
  });
4439
4439
 
4440
+ // src/utils/update-notification.ts
4441
+ function compareVersions(v1, v2) {
4442
+ const parts1 = v1.replace(/^v/, "").split(".").map(Number);
4443
+ const parts2 = v2.replace(/^v/, "").split(".").map(Number);
4444
+ for (let i = 0; i < 3; i++) {
4445
+ if ((parts1[i] || 0) < (parts2[i] || 0)) return -1;
4446
+ if ((parts1[i] || 0) > (parts2[i] || 0)) return 1;
4447
+ }
4448
+ return 0;
4449
+ }
4450
+ function shouldShowUpdateNotification({
4451
+ cache,
4452
+ currentVersion,
4453
+ hasJsonFlag,
4454
+ now = Date.now(),
4455
+ throttleWindowMs = UPDATE_NOTIFICATION_WINDOW_MS
4456
+ }) {
4457
+ if (!cache?.latestVersion) return false;
4458
+ if (hasJsonFlag) return false;
4459
+ if (compareVersions(currentVersion, cache.latestVersion) >= 0) return false;
4460
+ if (!cache.lastNotificationShown) return true;
4461
+ return now - cache.lastNotificationShown > throttleWindowMs;
4462
+ }
4463
+ var UPDATE_NOTIFICATION_WINDOW_MS;
4464
+ var init_update_notification = __esm({
4465
+ "src/utils/update-notification.ts"() {
4466
+ "use strict";
4467
+ UPDATE_NOTIFICATION_WINDOW_MS = 60 * 60 * 1e3;
4468
+ }
4469
+ });
4470
+
4440
4471
  // src/telemetry/event-queue.ts
4441
4472
  var import_fs2, import_path2, import_os2, GAL_DIR, QUEUE_FILE, PENDING_EVENTS_FILE, MAX_BATCH_SIZE, OFFLINE_TTL_DAYS, OFFLINE_TTL_MS, EventQueue;
4442
4473
  var init_event_queue = __esm({
@@ -4828,7 +4859,7 @@ function detectEnvironment() {
4828
4859
  return "dev";
4829
4860
  }
4830
4861
  try {
4831
- const version = true ? "0.0.290" : void 0;
4862
+ const version = true ? "0.0.292" : void 0;
4832
4863
  if (version && version.includes("-local")) {
4833
4864
  return "dev";
4834
4865
  }
@@ -5197,7 +5228,7 @@ function getId() {
5197
5228
  }
5198
5229
  function getCliVersion() {
5199
5230
  try {
5200
- return true ? "0.0.290" : "0.0.0-dev";
5231
+ return true ? "0.0.292" : "0.0.0-dev";
5201
5232
  } catch {
5202
5233
  return "0.0.0-dev";
5203
5234
  }
@@ -53010,7 +53041,7 @@ function fetchLatestVersion() {
53010
53041
  });
53011
53042
  });
53012
53043
  }
53013
- function compareVersions(v1, v2) {
53044
+ function compareVersions2(v1, v2) {
53014
53045
  const parts1 = v1.replace(/^v/, "").split(".").map(Number);
53015
53046
  const parts2 = v2.replace(/^v/, "").split(".").map(Number);
53016
53047
  for (let i = 0; i < 3; i++) {
@@ -53065,7 +53096,7 @@ function createUpdateCommand() {
53065
53096
  spinner.info(source_default.dim(`Pre-release version (${cliVersion7}) \u2014 skipping update check`));
53066
53097
  process.exit(0);
53067
53098
  }
53068
- const needsUpdate = compareVersions(cliVersion7, latestVersion) < 0;
53099
+ const needsUpdate = compareVersions2(cliVersion7, latestVersion) < 0;
53069
53100
  if (!needsUpdate) {
53070
53101
  spinner.succeed(source_default.green(`Already up to date (v${cliVersion7})`));
53071
53102
  process.exit(0);
@@ -54209,15 +54240,6 @@ function getRegistryAuthToken2() {
54209
54240
  return void 0;
54210
54241
  }
54211
54242
  }
54212
- function compareVersions2(v1, v2) {
54213
- const parts1 = v1.replace(/^v/, "").split(".").map(Number);
54214
- const parts2 = v2.replace(/^v/, "").split(".").map(Number);
54215
- for (let i = 0; i < 3; i++) {
54216
- if ((parts1[i] || 0) < (parts2[i] || 0)) return -1;
54217
- if ((parts1[i] || 0) > (parts2[i] || 0)) return 1;
54218
- }
54219
- return 0;
54220
- }
54221
54243
  function readUpdateCache() {
54222
54244
  try {
54223
54245
  if ((0, import_fs40.existsSync)(UPDATE_CACHE_FILE)) {
@@ -54269,14 +54291,21 @@ function checkForUpdates() {
54269
54291
  const hasJsonFlag = process.argv.includes("--json") || process.argv.includes("-j");
54270
54292
  const cache = readUpdateCache();
54271
54293
  const shouldRefresh = !cache || Date.now() - cache.lastCheck > ONE_DAY;
54272
- const shouldShowNotification = cache?.latestVersion && compareVersions2(cliVersion9, cache.latestVersion) < 0 && !hasJsonFlag && (!cache.lastNotificationShown || Date.now() - cache.lastNotificationShown > ONE_HOUR);
54273
- if (shouldShowNotification) {
54294
+ const now = Date.now();
54295
+ const shouldShowNotification = shouldShowUpdateNotification({
54296
+ cache,
54297
+ currentVersion: cliVersion9,
54298
+ hasJsonFlag,
54299
+ now
54300
+ });
54301
+ if (shouldShowNotification && cache?.latestVersion) {
54274
54302
  process.on("exit", () => {
54275
54303
  showUpdateNotification(cliVersion9, cache.latestVersion);
54276
54304
  });
54277
54305
  writeUpdateCache({
54278
- ...cache,
54279
- lastNotificationShown: Date.now()
54306
+ lastCheck: cache.lastCheck,
54307
+ latestVersion: cache.latestVersion,
54308
+ lastNotificationShown: now
54280
54309
  });
54281
54310
  const isUpdateCommand = process.argv.includes("update");
54282
54311
  const autoUpdateDisabled = process.env.GAL_NO_AUTO_UPDATE === "1" || process.env.CI === "true";
@@ -54447,7 +54476,7 @@ function refreshOrgMemberships() {
54447
54476
  } catch {
54448
54477
  }
54449
54478
  }
54450
- var import_dotenv, import_https2, import_child_process13, import_fs40, import_path39, import_os26, originalEmit, GLOBAL_TIMEOUT_MS, globalTimeout, cliVersion9, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE, ONE_DAY, ONE_HOUR, REGISTRY_URL2, REGISTRY_HOST, sessionStartTime, isReadOnlyStatusCommand, isMachineMode, exitHooksRan, featureFlags, knownCommands, isKnownCommand, program2, allInternalFlags;
54479
+ var import_dotenv, import_https2, import_child_process13, import_fs40, import_path39, import_os26, originalEmit, GLOBAL_TIMEOUT_MS, globalTimeout, cliVersion9, UPDATE_CACHE_DIR, UPDATE_CACHE_FILE, ONE_DAY, REGISTRY_URL2, REGISTRY_HOST, sessionStartTime, isReadOnlyStatusCommand, isMachineMode, exitHooksRan, featureFlags, knownCommands, isKnownCommand, program2, allInternalFlags;
54451
54480
  var init_index = __esm({
54452
54481
  "src/index.ts"() {
54453
54482
  "use strict";
@@ -54463,6 +54492,7 @@ var init_index = __esm({
54463
54492
  init_sentry2();
54464
54493
  init_path_conflict();
54465
54494
  init_install();
54495
+ init_update_notification();
54466
54496
  init_telemetry();
54467
54497
  init_terms_acceptance();
54468
54498
  init_research_preview();
@@ -54489,7 +54519,6 @@ var init_index = __esm({
54489
54519
  UPDATE_CACHE_DIR = (0, import_path39.join)((0, import_os26.homedir)(), ".gal");
54490
54520
  UPDATE_CACHE_FILE = (0, import_path39.join)(UPDATE_CACHE_DIR, "update-cache.json");
54491
54521
  ONE_DAY = 24 * 60 * 60 * 1e3;
54492
- ONE_HOUR = 60 * 60 * 1e3;
54493
54522
  REGISTRY_URL2 = (() => {
54494
54523
  const raw = process.env.GAL_REGISTRY_URL || "https://registry.npmjs.org";
54495
54524
  try {
@@ -54635,7 +54664,7 @@ var init_index = __esm({
54635
54664
  });
54636
54665
 
54637
54666
  // src/bootstrap.ts
54638
- var cliVersion10 = true ? "0.0.290" : "0.0.0-dev";
54667
+ var cliVersion10 = true ? "0.0.292" : "0.0.0-dev";
54639
54668
  var args = process.argv.slice(2);
54640
54669
  var requestedGlobalHelp = args.length === 1 && (args[0] === "--help" || args[0] === "-h");
54641
54670
  var requestedVersion = args.length === 1 && (args[0] === "--version" || args[0] === "-V");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scheduler-systems/gal-run",
3
- "version": "0.0.290",
3
+ "version": "0.0.292",
4
4
  "description": "GAL CLI - Command-line tool for managing AI agent configurations across your organization",
5
5
  "license": "Elastic-2.0",
6
6
  "private": false,
@@ -13,6 +13,8 @@
13
13
  "dist/index.cjs",
14
14
  "dist/postinstall.cjs",
15
15
  "dist/preuninstall.cjs",
16
+ "scripts/postinstall.cjs",
17
+ "scripts/preuninstall.cjs",
16
18
  "README.md",
17
19
  "LICENSE"
18
20
  ],
@@ -0,0 +1,932 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GAL CLI Postinstall Script
4
+ *
5
+ * Automatically installs Claude Code integrations when GAL CLI is installed via pnpm.
6
+ * This script runs as a pnpm lifecycle hook after package installation completes.
7
+ *
8
+ * What it installs:
9
+ * 1. SessionStart hook → ~/.claude/hooks/gal-sync-reminder.js
10
+ * - Shows sync status at the start of each Claude session
11
+ * - Prompts user to login/sync if needed
12
+ * 2. Status line script → ~/.claude/status_lines/gal-sync-status.py
13
+ * - Displays sync warnings in Claude's status bar (when not synced)
14
+ * 3. GAL CLI rules → ~/.claude/rules/gal-cli.md
15
+ * - Provides persistent GAL CLI awareness to Claude Code
16
+ *
17
+ * Scope:
18
+ * - These are CLI-level integrations (user-wide, not project-specific)
19
+ * - Org-specific configs are handled by `gal sync --pull`
20
+ *
21
+ * Key behaviors:
22
+ * - Idempotent: Safe to run multiple times, only updates when versions change
23
+ * - Version-aware: Checks version markers before overwriting files
24
+ * - Self-cleaning: Installed scripts remove themselves if GAL CLI is uninstalled
25
+ * - Non-destructive: Won't overwrite user's custom configs (e.g., custom statusLine)
26
+ * - Telemetry: Queues installation event for next CLI run (GAL-114)
27
+ *
28
+ * When it runs:
29
+ * - Automatically during `pnpm add -g @scheduler-systems/gal-run`
30
+ * - Can be manually triggered via `pnpm run postinstall` in the CLI package directory
31
+ *
32
+ * Prerequisites:
33
+ * - Node.js 18+ (CommonJS module)
34
+ * - Writable ~/.claude directory
35
+ *
36
+ * Related files:
37
+ * - apps/cli/scripts/preuninstall.cjs - Cleanup script (runs before uninstall)
38
+ * - apps/cli/package.json - pnpm lifecycle hooks configuration
39
+ *
40
+ * @see specs/gal-cli/installation.md
41
+ */
42
+
43
+ const fs = require('fs');
44
+ const path = require('path');
45
+ const os = require('os');
46
+
47
+ // =============================================================================
48
+ // Version Configuration
49
+ // =============================================================================
50
+ // Version markers control when files get updated.
51
+ // Bump these when file contents change to trigger reinstallation.
52
+
53
+ // Get current CLI version from package.json
54
+ const cliPackageJson = require('../package.json');
55
+ const cliVersion = cliPackageJson.version;
56
+
57
+ // Version markers for idempotency checks
58
+ // Bump these to force updates to installed files
59
+ const HOOK_VERSION = '4.0.0'; // SessionStart hook (4.0.0: Background cache refresh + sync)
60
+ const STATUS_LINE_VERSION = '1.0.0'; // Status line script
61
+ const RULES_VERSION = '1.0.0'; // GAL CLI rules
62
+
63
+ // =============================================================================
64
+ // GAL CLI Rules Content
65
+ // =============================================================================
66
+ // Injected into ~/.claude/rules/gal-cli.md
67
+ // Provides persistent GAL CLI awareness to Claude without hook overhead.
68
+ // Claude automatically loads rules from ~/.claude/rules/ at session start.
69
+ // =============================================================================
70
+
71
+ const GAL_CLI_RULES_CONTENT = `# GAL CLI
72
+
73
+ <!-- GAL_RULES_VERSION = "${RULES_VERSION}" -->
74
+
75
+ The \`gal\` CLI is available for managing org-approved AI agent configurations.
76
+
77
+ ## Available Commands
78
+ - \`gal sync --pull\` - Download latest approved config from your organization
79
+ - \`gal auth login\` - Authenticate with GitHub
80
+ - \`gal --help\` - See all available commands
81
+
82
+ ## Behavior Rules
83
+ - **Confirmation Required**: Always ask the user before running any \`gal\` command
84
+ - **Self-Discovery**: If unsure about syntax, run \`gal --help\` or \`gal <command> --help\` first
85
+ - **Sync Notifications**: When you see a GAL sync notification, ask: "Do you want me to sync gal now?"
86
+ `;
87
+
88
+ // =============================================================================
89
+ // SessionStart Hook Content
90
+ // =============================================================================
91
+ // Shows sync status notification at Claude session start.
92
+ // Appears once at the top of the chat window and stays visible.
93
+ // For continuous status updates, see the status line script below.
94
+ // =============================================================================
95
+
96
+ const HOOK_CONTENT = `#!/usr/bin/env node
97
+ /**
98
+ * GAL Config Sync Hook for Claude Code (SessionStart)
99
+ * Version: ${HOOK_VERSION}
100
+ *
101
+ * Shows sync status at session start:
102
+ * - Not authenticated → prompt to login
103
+ * - Token expired → prompt to re-login
104
+ * - Not synced → prompt to sync
105
+ * - Config outdated → prompt to sync
106
+ * - All good → show synced status
107
+ *
108
+ * Self-cleaning: removes itself if GAL CLI is uninstalled.
109
+ */
110
+
111
+ // GAL_HOOK_VERSION = "${HOOK_VERSION}"
112
+
113
+ const fs = require('fs');
114
+ const path = require('path');
115
+ const { execSync, spawn } = require('child_process');
116
+ const os = require('os');
117
+
118
+ const GAL_DIR = '.gal';
119
+ const SYNC_STATE_FILE = 'sync-state.json';
120
+ const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
121
+
122
+ function showMessage(message, status) {
123
+ // Queue telemetry event before showing message
124
+ queueTelemetryEvent(status);
125
+ console.log(JSON.stringify({ systemMessage: message }));
126
+ process.exit(0);
127
+ }
128
+
129
+ // =============================================================================
130
+ // Telemetry: Queue events for CLI to send on next run (GAL-114)
131
+ // =============================================================================
132
+ // Hook runs in Claude context (no network access), so we queue telemetry events
133
+ // in a JSON file that the CLI reads and flushes on next run. This allows us to
134
+ // track hook executions without blocking the user or requiring network calls.
135
+ // =============================================================================
136
+
137
+ /**
138
+ * Queue a telemetry event for the next CLI run.
139
+ *
140
+ * Since hooks run synchronously in Claude's context, we can't send telemetry
141
+ * directly. Instead, we write events to a pending queue file that the CLI
142
+ * reads and flushes on its next execution.
143
+ *
144
+ * @param {string} status - Sync status from the hook (auth_required, synced, etc.)
145
+ */
146
+ function queueTelemetryEvent(status) {
147
+ const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
148
+ const galDir = path.join(os.homedir(), '.gal');
149
+
150
+ let pending = [];
151
+ try {
152
+ if (fs.existsSync(pendingEventsPath)) {
153
+ pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
154
+ }
155
+ } catch {}
156
+
157
+ // Add session start hook event
158
+ pending.push({
159
+ id: require('crypto').randomUUID(),
160
+ eventType: 'hook_triggered',
161
+ timestamp: new Date().toISOString(),
162
+ payload: {
163
+ notificationType: 'session_start',
164
+ hookVersion: '${HOOK_VERSION}',
165
+ status: status,
166
+ cwd: process.cwd(),
167
+ platform: process.platform,
168
+ nodeVersion: process.version,
169
+ },
170
+ queuedAt: Date.now(),
171
+ });
172
+
173
+ try {
174
+ if (!fs.existsSync(galDir)) {
175
+ fs.mkdirSync(galDir, { recursive: true });
176
+ }
177
+ fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
178
+ } catch {
179
+ // Ignore errors - telemetry is optional
180
+ }
181
+ }
182
+
183
+ // =============================================================================
184
+ // Self-cleaning: Remove hook if GAL CLI is uninstalled
185
+ // =============================================================================
186
+
187
+ function isGalInstalled() {
188
+ try {
189
+ execSync('which gal', { stdio: 'ignore' });
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ function selfClean() {
197
+ const hookPath = __filename;
198
+ const claudeDir = path.join(os.homedir(), '.claude');
199
+ const settingsPath = path.join(claudeDir, 'settings.json');
200
+ const rulesPath = path.join(claudeDir, 'rules', 'gal-cli.md');
201
+
202
+ // Remove hook file
203
+ try { fs.unlinkSync(hookPath); } catch {}
204
+
205
+ // Remove rules file
206
+ try { fs.unlinkSync(rulesPath); } catch {}
207
+
208
+ // Remove hook entries from settings.json
209
+ try {
210
+ if (fs.existsSync(settingsPath)) {
211
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
212
+ const hookEvents = ['SessionStart', 'UserPromptSubmit'];
213
+
214
+ for (const event of hookEvents) {
215
+ if (settings.hooks?.[event]) {
216
+ settings.hooks[event] = settings.hooks[event].filter(entry => {
217
+ if (!entry.hooks) return true;
218
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
219
+ return entry.hooks.length > 0;
220
+ });
221
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
222
+ }
223
+ }
224
+
225
+ if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
226
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
227
+ }
228
+ } catch {}
229
+ }
230
+
231
+ // Check if GAL is installed, self-clean if not
232
+ if (!isGalInstalled()) {
233
+ selfClean();
234
+ process.exit(0);
235
+ }
236
+
237
+ // Read GAL CLI config (auth token, default org)
238
+ function readGalConfig() {
239
+ if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
240
+ try {
241
+ return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
242
+ } catch { return null; }
243
+ }
244
+
245
+ // Decode JWT without verification (just to check expiration)
246
+ function decodeJwt(token) {
247
+ try {
248
+ const parts = token.split('.');
249
+ if (parts.length !== 3) return null;
250
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
251
+ return payload;
252
+ } catch { return null; }
253
+ }
254
+
255
+ // =============================================================================
256
+ // Auto-update check: run gal update if a newer version is cached
257
+ // =============================================================================
258
+ function checkAndAutoUpdate() {
259
+ const updateCachePath = path.join(os.homedir(), '.gal', 'update-cache.json');
260
+ if (!fs.existsSync(updateCachePath)) return null;
261
+ try {
262
+ const cache = JSON.parse(fs.readFileSync(updateCachePath, 'utf-8'));
263
+ if (!cache.latestVersion) return null;
264
+ let currentVersion;
265
+ try {
266
+ currentVersion = execSync('gal --version', { stdio: 'pipe', timeout: 5000 }).toString().trim();
267
+ } catch { return null; }
268
+ const cv = currentVersion.replace(/^v/, '').split('.').map(Number);
269
+ const lv = cache.latestVersion.replace(/^v/, '').split('.').map(Number);
270
+ let needsUpdate = false;
271
+ for (let i = 0; i < 3; i++) {
272
+ if ((cv[i] || 0) < (lv[i] || 0)) { needsUpdate = true; break; }
273
+ if ((cv[i] || 0) > (lv[i] || 0)) break;
274
+ }
275
+ if (!needsUpdate) return null;
276
+ if (process.env.GAL_NO_AUTO_UPDATE === '1' || process.env.CI) return null;
277
+ try {
278
+ execSync('gal update', { stdio: 'pipe', timeout: 30000 });
279
+ return cache.latestVersion;
280
+ } catch { return null; }
281
+ } catch { return null; }
282
+ }
283
+
284
+ // Refresh update cache in background if stale (>24h)
285
+ function refreshUpdateCacheIfStale() {
286
+ try {
287
+ const updateCachePath = path.join(os.homedir(), '.gal', 'update-cache.json');
288
+ let needsRefresh = true;
289
+ if (fs.existsSync(updateCachePath)) {
290
+ try {
291
+ const cache = JSON.parse(fs.readFileSync(updateCachePath, 'utf-8'));
292
+ if (cache.lastCheck && (Date.now() - cache.lastCheck) < 24 * 60 * 60 * 1000) {
293
+ needsRefresh = false;
294
+ }
295
+ } catch {}
296
+ }
297
+ if (needsRefresh) {
298
+ const child = spawn('gal', ['update', '--check'], {
299
+ stdio: 'ignore',
300
+ detached: true,
301
+ });
302
+ child.unref();
303
+ }
304
+ } catch {}
305
+ }
306
+
307
+ const updatedVersion = checkAndAutoUpdate();
308
+ refreshUpdateCacheIfStale();
309
+
310
+ // Check authentication status
311
+ const galConfig = readGalConfig();
312
+
313
+ // Check 1: Not authenticated
314
+ if (!galConfig || !galConfig.authToken) {
315
+ showMessage("🔐 GAL: Authentication required.\\nRun: gal auth login", 'auth_required');
316
+ }
317
+
318
+ // Check 2: Token expired
319
+ const tokenPayload = decodeJwt(galConfig.authToken);
320
+ if (tokenPayload && tokenPayload.exp) {
321
+ const expiresAt = tokenPayload.exp * 1000;
322
+ if (Date.now() > expiresAt) {
323
+ showMessage("🔐 GAL: Session expired.\\nRun: gal auth login", 'token_expired');
324
+ }
325
+ }
326
+
327
+ // Check 3: Project not synced
328
+ function readSyncState() {
329
+ const statePath = path.join(process.cwd(), GAL_DIR, SYNC_STATE_FILE);
330
+ if (!fs.existsSync(statePath)) return null;
331
+ try {
332
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
333
+ } catch { return null; }
334
+ }
335
+
336
+ let state = readSyncState();
337
+
338
+ if (!state) {
339
+ // Attempt auto-sync
340
+ try {
341
+ execSync('gal sync --pull --auto', { stdio: 'pipe', timeout: 30000 });
342
+ state = readSyncState();
343
+ } catch {}
344
+
345
+ if (!state) {
346
+ const orgName = galConfig.defaultOrg || 'your organization';
347
+ showMessage(\`📥 GAL: Not synced with \${orgName}'s approved config.\\nRun: gal sync --pull\`, 'not_synced');
348
+ }
349
+ }
350
+
351
+ // Check 4: Config outdated
352
+ if (state && state.lastSyncHash !== state.approvedConfigHash) {
353
+ // Attempt auto-sync for outdated configs
354
+ try {
355
+ execSync('gal sync --pull --auto', { stdio: 'pipe', timeout: 30000 });
356
+ state = readSyncState();
357
+ } catch {}
358
+
359
+ if (state && state.lastSyncHash !== state.approvedConfigHash) {
360
+ const days = Math.floor((Date.now() - new Date(state.lastSyncTimestamp).getTime()) / (24 * 60 * 60 * 1000));
361
+ showMessage(\`⚠️ GAL: Config is \${days} day(s) behind \${state.organization}'s approved version.\\nRun: gal sync --pull\`, 'config_outdated');
362
+ }
363
+ }
364
+
365
+ // Check 5: Missing synced files
366
+ if (state && state.syncedFiles && state.syncedFiles.length > 0) {
367
+ const missingFiles = state.syncedFiles.filter(f => {
368
+ const fullPath = path.join(process.cwd(), f);
369
+ return !fs.existsSync(fullPath);
370
+ });
371
+
372
+ if (missingFiles.length > 0) {
373
+ showMessage(\`⚠️ GAL: Missing synced file(s): \${missingFiles.join(', ')}.\\nRun: gal sync --pull\`, 'missing_files');
374
+ }
375
+ }
376
+
377
+ // All good - build synced status message with optional dispatch rules
378
+ if (!state) {
379
+ showMessage("✅ GAL: Ready", 'synced');
380
+ }
381
+
382
+ let syncMessage = \`✅ GAL: Synced with \${state.organization}'s approved config (v\${state.version || 'latest'})\`;
383
+
384
+ if (updatedVersion) {
385
+ syncMessage = \`🔄 GAL: Updated to v\${updatedVersion}. \` + syncMessage;
386
+ }
387
+
388
+ // Inject dispatch rules summary if available
389
+ try {
390
+ const dispatchPath = path.join(process.cwd(), '.gal', 'dispatch-rules.json');
391
+ if (fs.existsSync(dispatchPath)) {
392
+ const rules = JSON.parse(fs.readFileSync(dispatchPath, 'utf-8'));
393
+ if (rules.enabled && rules.categories) {
394
+ const eligible = rules.categories.filter(c => c.enabled).map(c => c.name);
395
+ const local = rules.categories.filter(c => !c.enabled).map(c => c.name);
396
+ if (eligible.length > 0) {
397
+ syncMessage += \`\\n📋 Background dispatch: \${eligible.join(', ')} → use \\\`gal dispatch\\\`. \${local.length > 0 ? local.join(', ') + ' → keep local.' : ''}\`;
398
+ }
399
+ }
400
+ }
401
+ } catch {
402
+ // Dispatch rules are optional - ignore errors
403
+ }
404
+
405
+ showMessage(syncMessage, 'synced');
406
+ `;
407
+
408
+ // =============================================================================
409
+ // Status Line Script Content
410
+ // =============================================================================
411
+ // Python script that runs continuously in Claude's status bar.
412
+ // Shows warnings when not synced, silent when synced (avoids status bar spam).
413
+ // Uses uv's inline script runner for dependency management.
414
+ // =============================================================================
415
+
416
+ const STATUS_LINE_CONTENT = `#!/usr/bin/env -S uv run --script
417
+ # /// script
418
+ # requires-python = ">=3.11"
419
+ # dependencies = [
420
+ # "python-dotenv",
421
+ # ]
422
+ # ///
423
+ """
424
+ GAL Sync Status Line for Claude Code
425
+ Generated by GAL CLI
426
+
427
+ Version: ${STATUS_LINE_VERSION}
428
+
429
+ Behavior:
430
+ - NOT synced: Always show warning
431
+ - Synced: Silent (no output)
432
+ """
433
+
434
+ # GAL_STATUS_LINE_VERSION = "${STATUS_LINE_VERSION}"
435
+
436
+ import json
437
+ import os
438
+ import sys
439
+ import subprocess
440
+ from pathlib import Path
441
+
442
+ # =============================================================================
443
+ # CONFIGURATION
444
+ # =============================================================================
445
+ GAL_DIR = '.gal'
446
+ SYNC_STATE_FILE = 'sync-state.json'
447
+ GAL_CONFIG_FILE = Path.home() / '.gal' / 'config.json'
448
+
449
+
450
+ def is_gal_installed() -> bool:
451
+ """Check if GAL CLI is installed."""
452
+ try:
453
+ subprocess.run(['which', 'gal'], capture_output=True, check=True)
454
+ return True
455
+ except (subprocess.CalledProcessError, FileNotFoundError):
456
+ return False
457
+
458
+
459
+ def self_clean():
460
+ """Remove this status line if GAL CLI is uninstalled."""
461
+ script_path = Path(__file__).resolve()
462
+ settings_path = Path.home() / '.claude' / 'settings.json'
463
+
464
+ # Remove script file
465
+ try:
466
+ script_path.unlink()
467
+ except (OSError, IOError):
468
+ pass
469
+
470
+ # Remove from settings.json
471
+ try:
472
+ if settings_path.exists():
473
+ settings = json.loads(settings_path.read_text())
474
+ status_line_cmd = settings.get('statusLine', {}).get('command', '')
475
+ if 'gal-sync-status' in status_line_cmd:
476
+ del settings['statusLine']
477
+ settings_path.write_text(json.dumps(settings, indent=2))
478
+ except (json.JSONDecodeError, IOError):
479
+ pass
480
+
481
+
482
+ def read_gal_config():
483
+ """Read GAL CLI config (auth token, default org)."""
484
+ if not GAL_CONFIG_FILE.exists():
485
+ return None
486
+ try:
487
+ return json.loads(GAL_CONFIG_FILE.read_text())
488
+ except (json.JSONDecodeError, IOError):
489
+ return None
490
+
491
+
492
+ def read_sync_state():
493
+ """Read sync state from .gal/sync-state.json in current directory."""
494
+ state_path = Path.cwd() / GAL_DIR / SYNC_STATE_FILE
495
+ if not state_path.exists():
496
+ return None
497
+ try:
498
+ return json.loads(state_path.read_text())
499
+ except (json.JSONDecodeError, IOError):
500
+ return None
501
+
502
+
503
+ def generate_status_line(input_data):
504
+ """Generate the GAL sync status line.
505
+
506
+ Behavior:
507
+ - NOT synced: Always show warning (no throttle)
508
+ - Synced: Silent (no message)
509
+ """
510
+
511
+ # Self-clean if GAL is uninstalled
512
+ if not is_gal_installed():
513
+ self_clean()
514
+ return ""
515
+
516
+ # Read GAL config
517
+ gal_config = read_gal_config()
518
+
519
+ # Check 1: Not authenticated - always show
520
+ if not gal_config or not gal_config.get('authToken'):
521
+ return "\\033[33m🔐 GAL: login\\033[0m"
522
+
523
+ # Check 2: Project not synced - always show
524
+ state = read_sync_state()
525
+
526
+ if not state:
527
+ return "\\033[33m📥 GAL: sync\\033[0m"
528
+
529
+ # Check 3: Config outdated (hash mismatch) - always show
530
+ if state.get('lastSyncHash') != state.get('approvedConfigHash'):
531
+ return "\\033[33m⚠️ GAL: outdated\\033[0m"
532
+
533
+ # Check 4: Missing synced files - always show
534
+ synced_files = state.get('syncedFiles', [])
535
+ if synced_files:
536
+ missing = [f for f in synced_files if not (Path.cwd() / f).exists()]
537
+ if missing:
538
+ return "\\033[33m⚠️ GAL: missing files\\033[0m"
539
+
540
+ # Synced - stay silent
541
+ return ""
542
+
543
+
544
+ def main():
545
+ try:
546
+ # Read JSON input from stdin (Claude Code passes context)
547
+ input_data = json.loads(sys.stdin.read())
548
+
549
+ # Generate status line
550
+ status_line = generate_status_line(input_data)
551
+
552
+ # Only output if there's something to show
553
+ if status_line:
554
+ print(status_line)
555
+
556
+ sys.exit(0)
557
+
558
+ except json.JSONDecodeError:
559
+ # Handle JSON decode errors gracefully - stay silent
560
+ sys.exit(0)
561
+ except Exception:
562
+ # Handle any other errors gracefully - stay silent
563
+ sys.exit(0)
564
+
565
+
566
+ if __name__ == '__main__':
567
+ main()
568
+ `;
569
+
570
+ // =============================================================================
571
+ // Installation Functions
572
+ // =============================================================================
573
+
574
+ // =============================================================================
575
+ // Installation Functions - SessionStart Hook
576
+ // =============================================================================
577
+
578
+ /**
579
+ * Install the SessionStart hook to ~/.claude/hooks/gal-sync-reminder.js
580
+ *
581
+ * The hook shows sync status at the start of each Claude session, prompting
582
+ * users to login or sync if needed. It checks:
583
+ * 1. Authentication status (GAL CLI login)
584
+ * 2. Project sync state (gal sync --pull)
585
+ * 3. Config staleness (hash mismatch)
586
+ * 4. Missing synced files
587
+ *
588
+ * Key behaviors:
589
+ * - Idempotent: Checks HOOK_VERSION marker before writing
590
+ * - Migration: Cleans up old UserPromptSubmit hooks (v1.x used those)
591
+ * - Registration: Adds hook to ~/.claude/settings.json
592
+ * - Self-cleaning: Hook removes itself if GAL CLI is uninstalled
593
+ *
594
+ * @returns {boolean} True if hook was installed or updated, false on error
595
+ */
596
+ function installHook() {
597
+ const claudeDir = path.join(os.homedir(), '.claude');
598
+ const hooksDir = path.join(claudeDir, 'hooks');
599
+ const hookPath = path.join(hooksDir, 'gal-sync-reminder.js');
600
+ const settingsPath = path.join(claudeDir, 'settings.json');
601
+
602
+ try {
603
+ // Create directories if needed
604
+ if (!fs.existsSync(hooksDir)) {
605
+ fs.mkdirSync(hooksDir, { recursive: true });
606
+ }
607
+
608
+ // Check if hook already exists with current version
609
+ let needsUpdate = true;
610
+ if (fs.existsSync(hookPath)) {
611
+ const existingContent = fs.readFileSync(hookPath, 'utf-8');
612
+ const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
613
+ if (versionMatch && versionMatch[1] === HOOK_VERSION) {
614
+ needsUpdate = false;
615
+ }
616
+ }
617
+
618
+ // Write the hook file if needed
619
+ if (needsUpdate) {
620
+ fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf-8');
621
+ fs.chmodSync(hookPath, '755');
622
+ }
623
+
624
+ // Update settings.json
625
+ let settings = {};
626
+ if (fs.existsSync(settingsPath)) {
627
+ try {
628
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
629
+ } catch {
630
+ settings = {};
631
+ }
632
+ }
633
+
634
+ // CLEANUP: Remove old UserPromptSubmit hooks (v1.x migration)
635
+ // GAL CLI v1.x used UserPromptSubmit hooks, but they caused performance issues
636
+ // by running on every user message. v2.x uses SessionStart hooks instead.
637
+ if (settings.hooks?.UserPromptSubmit) {
638
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(entry => {
639
+ if (!entry.hooks) return true;
640
+ entry.hooks = entry.hooks.filter(h =>
641
+ !h.command?.includes('gal-') && !h.command?.includes('/gal/')
642
+ );
643
+ return entry.hooks.length > 0;
644
+ });
645
+ if (settings.hooks.UserPromptSubmit.length === 0) {
646
+ delete settings.hooks.UserPromptSubmit;
647
+ }
648
+ }
649
+
650
+ // Register SessionStart hook if not already registered
651
+ const hookCommand = `node ${hookPath}`;
652
+ if (!settings.hooks) settings.hooks = {};
653
+ if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
654
+
655
+ const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
656
+ entry.hooks?.some(h => h.command?.includes('gal-sync-reminder'))
657
+ );
658
+
659
+ if (!alreadyRegistered) {
660
+ settings.hooks.SessionStart.push({
661
+ hooks: [{ type: 'command', command: hookCommand }]
662
+ });
663
+ }
664
+
665
+ // Write settings
666
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
667
+
668
+ if (needsUpdate) {
669
+ console.log('✓ GAL SessionStart hook installed');
670
+ }
671
+ return true;
672
+ } catch (error) {
673
+ // Silent fail - hook is optional enhancement
674
+ return false;
675
+ }
676
+ }
677
+
678
+ // =============================================================================
679
+ // Installation Functions - GAL CLI Rules
680
+ // =============================================================================
681
+
682
+ /**
683
+ * Install GAL CLI rules to ~/.claude/rules/gal-cli.md
684
+ *
685
+ * Rules provide persistent awareness of GAL CLI commands without hook overhead.
686
+ * Claude automatically loads rules from ~/.claude/rules/ at session start,
687
+ * so the AI knows about `gal` commands and can suggest their usage.
688
+ *
689
+ * Key behaviors:
690
+ * - Idempotent: Checks RULES_VERSION marker before writing
691
+ * - Lightweight: No runtime overhead (unlike hooks that execute on events)
692
+ * - Persistent: Remains loaded for entire Claude session
693
+ *
694
+ * @returns {boolean} True if rules were installed or updated, false on error
695
+ */
696
+ function installRules() {
697
+ const claudeDir = path.join(os.homedir(), '.claude');
698
+ const rulesDir = path.join(claudeDir, 'rules');
699
+ const rulesPath = path.join(rulesDir, 'gal-cli.md');
700
+
701
+ try {
702
+ // Create rules directory if needed
703
+ if (!fs.existsSync(rulesDir)) {
704
+ fs.mkdirSync(rulesDir, { recursive: true });
705
+ }
706
+
707
+ // Check if rules file already exists with current version
708
+ let needsUpdate = true;
709
+ if (fs.existsSync(rulesPath)) {
710
+ const existingContent = fs.readFileSync(rulesPath, 'utf-8');
711
+ const versionMatch = existingContent.match(/GAL_RULES_VERSION = "([^"]+)"/);
712
+ if (versionMatch && versionMatch[1] === RULES_VERSION) {
713
+ needsUpdate = false;
714
+ }
715
+ }
716
+
717
+ // Write the rules file if needed
718
+ if (needsUpdate) {
719
+ fs.writeFileSync(rulesPath, GAL_CLI_RULES_CONTENT, 'utf-8');
720
+ console.log('✓ GAL CLI rules installed');
721
+ }
722
+
723
+ return true;
724
+ } catch (error) {
725
+ // Silent fail - rules are optional enhancement
726
+ return false;
727
+ }
728
+ }
729
+
730
+ // =============================================================================
731
+ // Installation Functions - Telemetry Queue
732
+ // =============================================================================
733
+
734
+ /**
735
+ * Queue a telemetry event for the next CLI run (GAL-114)
736
+ *
737
+ * This postinstall script is CommonJS, but the telemetry module is ESM,
738
+ * so we can't import it directly. Instead, we write events to a pending
739
+ * file (~/.gal/telemetry-pending-events.json) that the CLI picks up and
740
+ * flushes on its next execution.
741
+ *
742
+ * Events are structured as:
743
+ * - id: Unique event identifier (UUID)
744
+ * - eventType: 'hook_triggered' for installation
745
+ * - timestamp: ISO 8601 timestamp
746
+ * - payload: Event-specific data (cliVersion, platform, nodeVersion)
747
+ * - queuedAt: Unix timestamp when event was queued
748
+ *
749
+ * @returns {void}
750
+ */
751
+ function queueTelemetryEvent() {
752
+ const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
753
+ const galDir = path.join(os.homedir(), '.gal');
754
+
755
+ let pending = [];
756
+ try {
757
+ if (fs.existsSync(pendingEventsPath)) {
758
+ pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
759
+ }
760
+ } catch {}
761
+
762
+ // Add postinstall hook event
763
+ pending.push({
764
+ id: require('crypto').randomUUID(),
765
+ eventType: 'hook_triggered',
766
+ timestamp: new Date().toISOString(),
767
+ payload: {
768
+ notificationType: 'postinstall',
769
+ cliVersion,
770
+ platform: process.platform,
771
+ nodeVersion: process.version,
772
+ },
773
+ queuedAt: Date.now(),
774
+ });
775
+
776
+ try {
777
+ if (!fs.existsSync(galDir)) {
778
+ fs.mkdirSync(galDir, { recursive: true });
779
+ }
780
+ fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
781
+ } catch {
782
+ // Ignore errors - telemetry is optional
783
+ }
784
+ }
785
+
786
+ // =============================================================================
787
+ // Installation Functions - Status Line Script
788
+ // =============================================================================
789
+
790
+ /**
791
+ * Install the status line script to ~/.claude/status_lines/gal-sync-status.py
792
+ *
793
+ * The status line script runs continuously in Claude's status bar, showing
794
+ * sync warnings when the project is not synced with the org's approved config.
795
+ * Silent when synced (avoids status bar spam).
796
+ *
797
+ * Key behaviors:
798
+ * - Idempotent: Checks STATUS_LINE_VERSION marker before writing
799
+ * - Respectful: Won't overwrite user's existing custom statusLine
800
+ * - Registration: Adds to ~/.claude/settings.json statusLine field
801
+ * - Self-cleaning: Script removes itself if GAL CLI is uninstalled
802
+ *
803
+ * @returns {boolean} True if status line was installed or updated, false on error
804
+ */
805
+ function installStatusLine() {
806
+ const claudeDir = path.join(os.homedir(), '.claude');
807
+ const statusLinesDir = path.join(claudeDir, 'status_lines');
808
+ const scriptPath = path.join(statusLinesDir, 'gal-sync-status.py');
809
+ const settingsPath = path.join(claudeDir, 'settings.json');
810
+
811
+ try {
812
+ // Check existing settings for custom statusLine
813
+ let settings = {};
814
+ if (fs.existsSync(settingsPath)) {
815
+ try {
816
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
817
+ } catch {
818
+ settings = {};
819
+ }
820
+ }
821
+
822
+ // Don't overwrite user's custom statusLine (respect user's existing config)
823
+ if (settings.statusLine?.command && !settings.statusLine.command.includes('gal-sync-status')) {
824
+ console.log('ℹ Custom statusLine detected, skipping GAL status line');
825
+ return false;
826
+ }
827
+
828
+ // Create directories if needed
829
+ if (!fs.existsSync(statusLinesDir)) {
830
+ fs.mkdirSync(statusLinesDir, { recursive: true });
831
+ }
832
+
833
+ // Check if script already exists with current version
834
+ let needsUpdate = true;
835
+ if (fs.existsSync(scriptPath)) {
836
+ const existingContent = fs.readFileSync(scriptPath, 'utf-8');
837
+ const versionMatch = existingContent.match(/GAL_STATUS_LINE_VERSION = "([^"]+)"/);
838
+ if (versionMatch && versionMatch[1] === STATUS_LINE_VERSION) {
839
+ // Also check if it's registered in settings
840
+ if (settings.statusLine?.command?.includes('gal-sync-status')) {
841
+ needsUpdate = false;
842
+ }
843
+ }
844
+ }
845
+
846
+ // Write the script file if needed
847
+ if (needsUpdate) {
848
+ fs.writeFileSync(scriptPath, STATUS_LINE_CONTENT, 'utf-8');
849
+ fs.chmodSync(scriptPath, '755');
850
+
851
+ // Register in settings.json
852
+ settings.statusLine = {
853
+ type: 'command',
854
+ command: scriptPath
855
+ };
856
+
857
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
858
+ console.log('✓ GAL status line installed');
859
+ }
860
+
861
+ return true;
862
+ } catch (error) {
863
+ // Silent fail - status line is optional enhancement
864
+ return false;
865
+ }
866
+ }
867
+
868
+ function detectPackageManager() {
869
+ const userAgent = process.env.npm_config_user_agent || '';
870
+
871
+ if (userAgent.startsWith('pnpm/')) {
872
+ return 'pnpm';
873
+ }
874
+
875
+ if (userAgent.startsWith('npm/')) {
876
+ return 'npm';
877
+ }
878
+
879
+ return 'unknown';
880
+ }
881
+
882
+ function recordInstallMetadata() {
883
+ try {
884
+ const galDir = path.join(os.homedir(), '.gal');
885
+ if (!fs.existsSync(galDir)) {
886
+ fs.mkdirSync(galDir, { recursive: true });
887
+ }
888
+
889
+ const packageManager = detectPackageManager();
890
+ const method = packageManager === 'pnpm' ? 'pnpm' : packageManager === 'npm' ? 'npm' : 'unknown';
891
+ const binaryPath = path.join(__dirname, '..', 'dist', 'index.cjs');
892
+ const metadataPath = path.join(galDir, 'install-metadata.json');
893
+ const metadata = {
894
+ binaryPath,
895
+ installedAt: new Date().toISOString(),
896
+ method,
897
+ packageManager,
898
+ platform: process.platform,
899
+ version: cliVersion,
900
+ };
901
+
902
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
903
+ } catch {
904
+ // Silent fail - metadata is best-effort only
905
+ }
906
+ }
907
+
908
+ // =============================================================================
909
+ // Main
910
+ // =============================================================================
911
+
912
+ function main() {
913
+ recordInstallMetadata();
914
+ const hookInstalled = installHook();
915
+ const rulesInstalled = installRules();
916
+ const statusLineInstalled = installStatusLine();
917
+
918
+ // Queue telemetry event (GAL-114)
919
+ queueTelemetryEvent();
920
+
921
+ if (hookInstalled || rulesInstalled || statusLineInstalled) {
922
+ console.log('');
923
+ console.log('Restart Claude Code/Cursor for changes to take effect.');
924
+ console.log('');
925
+ console.log('Next steps:');
926
+ console.log(' 1. gal auth login - Authenticate with GitHub');
927
+ console.log(' 2. gal sync --pull - Download org-approved config');
928
+ console.log('');
929
+ }
930
+ }
931
+
932
+ main();
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GAL CLI Preuninstall Script
4
+ *
5
+ * Automatically cleans up GAL-installed files when the CLI is uninstalled via pnpm.
6
+ * This script runs as a package lifecycle hook before package removal.
7
+ *
8
+ * What it cleans:
9
+ * 1. Hook files → ~/.claude/hooks/gal-*.js
10
+ * 2. Status line script → ~/.claude/status_lines/gal-sync-status.py
11
+ * 3. Rules file → ~/.claude/rules/gal-cli.md
12
+ * 4. Hook entries in ~/.claude/settings.json (preserves file and other hooks)
13
+ * 5. GAL config directory → ~/.gal/ (auth tokens, sync state, telemetry queue)
14
+ *
15
+ * Cleanup scope:
16
+ * - User-level files only (not project-level .gal directories)
17
+ * - GAL-specific entries only (preserves user's other Claude configs)
18
+ * - Silent fail strategy (won't prevent uninstall on errors)
19
+ *
20
+ * When it runs:
21
+ * - Automatically during `pnpm rm -g @scheduler-systems/gal-run`
22
+ * - Before package files are removed from node_modules
23
+ * - Can be manually triggered via `pnpm run preuninstall` in the CLI package directory
24
+ *
25
+ * Prerequisites:
26
+ * - Node.js 18+ (CommonJS module)
27
+ * - Runs with user's file system permissions
28
+ *
29
+ * Related files:
30
+ * - apps/cli/scripts/postinstall.cjs - Installation script
31
+ * - apps/cli/package.json - package lifecycle hooks configuration
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const os = require('os');
37
+
38
+ // =============================================================================
39
+ // Cleanup Functions - settings.json
40
+ // =============================================================================
41
+
42
+ /**
43
+ * Remove GAL hook entries from settings.json without deleting the file.
44
+ *
45
+ * This function surgically removes only GAL-specific hook entries while
46
+ * preserving the user's other hooks and settings. It handles:
47
+ * - Filtering GAL hooks from UserPromptSubmit array
48
+ * - Filtering GAL hooks from SessionStart array (v2.x)
49
+ * - Removing empty hook arrays after filtering
50
+ * - Preserving the settings.json file structure
51
+ *
52
+ * @param {string} settingsPath - Full path to ~/.claude/settings.json
53
+ * @returns {boolean} True if any GAL entries were removed, false otherwise
54
+ */
55
+ function removeGalHookEntries(settingsPath) {
56
+ try {
57
+ if (!fs.existsSync(settingsPath)) {
58
+ return false;
59
+ }
60
+
61
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
62
+
63
+ if (!settings.hooks?.UserPromptSubmit) {
64
+ return false;
65
+ }
66
+
67
+ // Filter out GAL hooks while preserving user's other hooks
68
+ const originalLength = settings.hooks.UserPromptSubmit.length;
69
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((entry) => {
70
+ if (!entry.hooks) return true;
71
+ // Keep entry only if it has non-GAL hooks
72
+ entry.hooks = entry.hooks.filter((hook) =>
73
+ !hook.command?.includes('gal-') && !hook.command?.includes('/gal/')
74
+ );
75
+ return entry.hooks.length > 0;
76
+ });
77
+
78
+ // Remove empty hooks array
79
+ if (settings.hooks.UserPromptSubmit.length === 0) {
80
+ delete settings.hooks.UserPromptSubmit;
81
+ }
82
+
83
+ // Remove empty hooks object
84
+ if (Object.keys(settings.hooks).length === 0) {
85
+ delete settings.hooks;
86
+ }
87
+
88
+ if (settings.hooks?.UserPromptSubmit?.length !== originalLength) {
89
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
90
+ return true;
91
+ }
92
+
93
+ return false;
94
+ } catch {
95
+ // Silent fail - don't prevent uninstall if settings.json is corrupted
96
+ return false;
97
+ }
98
+ }
99
+
100
+ // =============================================================================
101
+ // Cleanup Functions - User-Level Files
102
+ // =============================================================================
103
+
104
+ /**
105
+ * Clean up GAL-installed files from user's home directory.
106
+ *
107
+ * Removes:
108
+ * - Hook files: ~/.claude/hooks/gal-*.js
109
+ * - Status line script: ~/.claude/status_lines/gal-sync-status.py
110
+ * - Rules file: ~/.claude/rules/gal-cli.md
111
+ * - Hook entries from settings.json
112
+ *
113
+ * Uses silent fail strategy - logs removed files but doesn't throw errors.
114
+ * This ensures uninstall proceeds even if individual files are missing or
115
+ * have permission issues.
116
+ *
117
+ * @returns {string[]} Array of paths to files that were successfully removed
118
+ */
119
+ function cleanupUserLevel() {
120
+ const claudeDir = path.join(os.homedir(), '.claude');
121
+ const hooksDir = path.join(claudeDir, 'hooks');
122
+ const settingsPath = path.join(claudeDir, 'settings.json');
123
+
124
+ let removed = [];
125
+
126
+ // Remove GAL hook files
127
+ if (fs.existsSync(hooksDir)) {
128
+ try {
129
+ const files = fs.readdirSync(hooksDir);
130
+ for (const file of files) {
131
+ if (file.startsWith('gal-')) {
132
+ const hookPath = path.join(hooksDir, file);
133
+ try {
134
+ fs.unlinkSync(hookPath);
135
+ removed.push(hookPath);
136
+ } catch (err) {
137
+ // Silent fail
138
+ }
139
+ }
140
+ }
141
+ } catch (err) {
142
+ // Silent fail
143
+ }
144
+ }
145
+
146
+ // Remove GAL hook entries from settings.json
147
+ if (removeGalHookEntries(settingsPath)) {
148
+ removed.push(`${settingsPath} (GAL hooks removed)`);
149
+ }
150
+
151
+ return removed;
152
+ }
153
+
154
+ // =============================================================================
155
+ // Cleanup Functions - GAL Config Directory
156
+ // =============================================================================
157
+
158
+ /**
159
+ * Clean up GAL config directory from user's home directory.
160
+ *
161
+ * Removes entire ~/.gal directory, including:
162
+ * - config.json (auth token, default org)
163
+ * - telemetry-pending-events.json (queued telemetry events)
164
+ * - Any other GAL-specific cache or state files
165
+ *
166
+ * Note: Does NOT remove project-level .gal directories (those belong to repos)
167
+ *
168
+ * Uses silent fail strategy to ensure uninstall proceeds even if directory
169
+ * is missing or has permission issues.
170
+ *
171
+ * @returns {string[]} Array containing the removed directory path, or empty array
172
+ */
173
+ function cleanupGalConfig() {
174
+ const galConfigDir = path.join(os.homedir(), '.gal');
175
+
176
+ if (fs.existsSync(galConfigDir)) {
177
+ try {
178
+ fs.rmSync(galConfigDir, { recursive: true, force: true });
179
+ return [galConfigDir];
180
+ } catch (err) {
181
+ // Silent fail
182
+ return [];
183
+ }
184
+ }
185
+
186
+ return [];
187
+ }
188
+
189
+ // =============================================================================
190
+ // Main Cleanup Orchestration
191
+ // =============================================================================
192
+
193
+ /**
194
+ * Main cleanup orchestration function.
195
+ *
196
+ * Coordinates all cleanup operations and provides user feedback.
197
+ * Runs both user-level and config directory cleanups, then reports results.
198
+ *
199
+ * @returns {void}
200
+ */
201
+ function cleanup() {
202
+ console.log('\n═══════════════════════════════════════════════════');
203
+ console.log(' GAL CLI Uninstall Cleanup');
204
+ console.log('═══════════════════════════════════════════════════\n');
205
+
206
+ const userLevelFiles = cleanupUserLevel();
207
+ const galConfigFiles = cleanupGalConfig();
208
+ const allRemoved = [...userLevelFiles, ...galConfigFiles];
209
+
210
+ if (allRemoved.length > 0) {
211
+ console.log('✓ Cleaned up GAL files:');
212
+ for (const file of allRemoved) {
213
+ console.log(` - ${file}`);
214
+ }
215
+ } else {
216
+ console.log('No GAL files found to clean up.');
217
+ }
218
+
219
+ console.log('\n═══════════════════════════════════════════════════');
220
+ console.log(' GAL CLI has been uninstalled');
221
+ console.log('═══════════════════════════════════════════════════\n');
222
+ console.log('To reinstall: pnpm add -g @scheduler-systems/gal-run\n');
223
+ }
224
+
225
+ // =============================================================================
226
+ // Entry Point
227
+ // =============================================================================
228
+
229
+ // Run cleanup with silent fail strategy
230
+ // Even if cleanup fails, we don't prevent npm uninstall from proceeding
231
+ try {
232
+ cleanup();
233
+ } catch (error) {
234
+ // Log error but allow uninstall to continue
235
+ console.error('GAL cleanup encountered an error, but uninstall will proceed.');
236
+ // Note: We don't process.exit(1) here - uninstall should always succeed
237
+ }