@scheduler-systems/gal 0.1.1-beta

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,694 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GAL CLI Postinstall Script
4
+ *
5
+ * Automatically installs Claude Code integrations when GAL CLI is installed:
6
+ * 1. SessionStart hook → ~/.claude/hooks/gal-sync-reminder.js
7
+ * 2. Status line script → ~/.claude/status_lines/gal-sync-status.py
8
+ * 3. GAL CLI rules → ~/.claude/rules/gal-cli.md
9
+ *
10
+ * These are CLI-level configs (not org-specific).
11
+ * Org-specific configs are handled by `gal sync --pull`.
12
+ *
13
+ * Key behaviors:
14
+ * - Idempotent: Safe to run multiple times
15
+ * - Version-aware: Updates outdated scripts
16
+ * - Self-cleaning: Scripts remove themselves if GAL CLI is uninstalled
17
+ * - Non-destructive: Won't overwrite user's custom configs
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ // Get current CLI version from package.json
25
+ const cliPackageJson = require('../package.json');
26
+ const CLI_VERSION = cliPackageJson.version;
27
+
28
+ // Version markers for idempotency
29
+ const HOOK_VERSION = '2.1.0'; // SessionStart version (2.1.0: Added telemetry tracking)
30
+ const STATUS_LINE_VERSION = '1.0.0';
31
+ const RULES_VERSION = '1.0.0';
32
+
33
+ // =============================================================================
34
+ // GAL CLI Rules Content
35
+ // =============================================================================
36
+ // This is injected into ~/.claude/rules/gal-cli.md
37
+ // Provides persistent GAL CLI awareness without running on every prompt
38
+ // =============================================================================
39
+
40
+ const GAL_CLI_RULES_CONTENT = `# GAL CLI
41
+
42
+ <!-- GAL_RULES_VERSION = "${RULES_VERSION}" -->
43
+
44
+ The \`gal\` CLI is available for managing org-approved AI agent configurations.
45
+
46
+ ## Available Commands
47
+ - \`gal sync --pull\` - Download latest approved config from your organization
48
+ - \`gal auth login\` - Authenticate with GitHub
49
+ - \`gal --help\` - See all available commands
50
+
51
+ ## Behavior Rules
52
+ - **Confirmation Required**: Always ask the user before running any \`gal\` command
53
+ - **Self-Discovery**: If unsure about syntax, run \`gal --help\` or \`gal <command> --help\` first
54
+ - **Sync Notifications**: When you see a GAL sync notification, ask: "Do you want me to sync gal now?"
55
+ `;
56
+
57
+ // =============================================================================
58
+ // SessionStart Hook Content
59
+ // =============================================================================
60
+ // Shows sync status at session start (appears at top of chat, stays there).
61
+ // Periodic reminders are handled by the status line.
62
+ // =============================================================================
63
+
64
+ const HOOK_CONTENT = `#!/usr/bin/env node
65
+ /**
66
+ * GAL Config Sync Hook for Claude Code (SessionStart)
67
+ * Version: ${HOOK_VERSION}
68
+ *
69
+ * Shows sync status at session start:
70
+ * - Not authenticated → prompt to login
71
+ * - Token expired → prompt to re-login
72
+ * - Not synced → prompt to sync
73
+ * - Config outdated → prompt to sync
74
+ * - All good → show synced status
75
+ *
76
+ * Self-cleaning: removes itself if GAL CLI is uninstalled.
77
+ */
78
+
79
+ // GAL_HOOK_VERSION = "${HOOK_VERSION}"
80
+
81
+ const fs = require('fs');
82
+ const path = require('path');
83
+ const { execSync } = require('child_process');
84
+ const os = require('os');
85
+
86
+ const GAL_DIR = '.gal';
87
+ const SYNC_STATE_FILE = 'sync-state.json';
88
+ const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
89
+
90
+ function showMessage(message, status) {
91
+ // Queue telemetry event before showing message
92
+ queueTelemetryEvent(status);
93
+ console.log(JSON.stringify({ systemMessage: message }));
94
+ process.exit(0);
95
+ }
96
+
97
+ // =============================================================================
98
+ // Telemetry: Queue events for CLI to send on next run (GAL-114)
99
+ // =============================================================================
100
+
101
+ function queueTelemetryEvent(status) {
102
+ const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
103
+ const galDir = path.join(os.homedir(), '.gal');
104
+
105
+ let pending = [];
106
+ try {
107
+ if (fs.existsSync(pendingEventsPath)) {
108
+ pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
109
+ }
110
+ } catch {}
111
+
112
+ // Add session start hook event
113
+ pending.push({
114
+ id: require('crypto').randomUUID(),
115
+ eventType: 'hook_triggered',
116
+ timestamp: new Date().toISOString(),
117
+ payload: {
118
+ notificationType: 'session_start',
119
+ hookVersion: '${HOOK_VERSION}',
120
+ status: status,
121
+ cwd: process.cwd(),
122
+ platform: process.platform,
123
+ nodeVersion: process.version,
124
+ },
125
+ queuedAt: Date.now(),
126
+ });
127
+
128
+ try {
129
+ if (!fs.existsSync(galDir)) {
130
+ fs.mkdirSync(galDir, { recursive: true });
131
+ }
132
+ fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
133
+ } catch {
134
+ // Ignore errors - telemetry is optional
135
+ }
136
+ }
137
+
138
+ // =============================================================================
139
+ // Self-cleaning: Remove hook if GAL CLI is uninstalled
140
+ // =============================================================================
141
+
142
+ function isGalInstalled() {
143
+ try {
144
+ execSync('which gal', { stdio: 'ignore' });
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ function selfClean() {
152
+ const hookPath = __filename;
153
+ const claudeDir = path.join(os.homedir(), '.claude');
154
+ const settingsPath = path.join(claudeDir, 'settings.json');
155
+ const rulesPath = path.join(claudeDir, 'rules', 'gal-cli.md');
156
+
157
+ // Remove hook file
158
+ try { fs.unlinkSync(hookPath); } catch {}
159
+
160
+ // Remove rules file
161
+ try { fs.unlinkSync(rulesPath); } catch {}
162
+
163
+ // Remove hook entries from settings.json
164
+ try {
165
+ if (fs.existsSync(settingsPath)) {
166
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
167
+ const hookEvents = ['SessionStart', 'UserPromptSubmit'];
168
+
169
+ for (const event of hookEvents) {
170
+ if (settings.hooks?.[event]) {
171
+ settings.hooks[event] = settings.hooks[event].filter(entry => {
172
+ if (!entry.hooks) return true;
173
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
174
+ return entry.hooks.length > 0;
175
+ });
176
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
177
+ }
178
+ }
179
+
180
+ if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
181
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
182
+ }
183
+ } catch {}
184
+ }
185
+
186
+ // Check if GAL is installed, self-clean if not
187
+ if (!isGalInstalled()) {
188
+ selfClean();
189
+ process.exit(0);
190
+ }
191
+
192
+ // Read GAL CLI config (auth token, default org)
193
+ function readGalConfig() {
194
+ if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
195
+ try {
196
+ return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
197
+ } catch { return null; }
198
+ }
199
+
200
+ // Decode JWT without verification (just to check expiration)
201
+ function decodeJwt(token) {
202
+ try {
203
+ const parts = token.split('.');
204
+ if (parts.length !== 3) return null;
205
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
206
+ return payload;
207
+ } catch { return null; }
208
+ }
209
+
210
+ // Check authentication status
211
+ const galConfig = readGalConfig();
212
+
213
+ // Check 1: Not authenticated
214
+ if (!galConfig || !galConfig.authToken) {
215
+ showMessage("🔐 GAL: Authentication required.\\nRun: gal auth login", 'auth_required');
216
+ }
217
+
218
+ // Check 2: Token expired
219
+ const tokenPayload = decodeJwt(galConfig.authToken);
220
+ if (tokenPayload && tokenPayload.exp) {
221
+ const expiresAt = tokenPayload.exp * 1000;
222
+ if (Date.now() > expiresAt) {
223
+ showMessage("🔐 GAL: Session expired.\\nRun: gal auth login", 'token_expired');
224
+ }
225
+ }
226
+
227
+ // Check 3: Project not synced
228
+ function readSyncState() {
229
+ const statePath = path.join(process.cwd(), GAL_DIR, SYNC_STATE_FILE);
230
+ if (!fs.existsSync(statePath)) return null;
231
+ try {
232
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
233
+ } catch { return null; }
234
+ }
235
+
236
+ const state = readSyncState();
237
+
238
+ if (!state) {
239
+ const orgName = galConfig.defaultOrg || 'your organization';
240
+ showMessage(\`📥 GAL: Not synced with \${orgName}'s approved config.\\nRun: gal sync --pull\`, 'not_synced');
241
+ }
242
+
243
+ // Check 4: Config outdated
244
+ if (state.lastSyncHash !== state.approvedConfigHash) {
245
+ const days = Math.floor((Date.now() - new Date(state.lastSyncTimestamp).getTime()) / (24 * 60 * 60 * 1000));
246
+ showMessage(\`⚠️ GAL: Config is \${days} day(s) behind \${state.organization}'s approved version.\\nRun: gal sync --pull\`, 'config_outdated');
247
+ }
248
+
249
+ // Check 5: Missing synced files
250
+ if (state.syncedFiles && state.syncedFiles.length > 0) {
251
+ const missingFiles = state.syncedFiles.filter(f => {
252
+ const fullPath = path.join(process.cwd(), f);
253
+ return !fs.existsSync(fullPath);
254
+ });
255
+
256
+ if (missingFiles.length > 0) {
257
+ showMessage(\`⚠️ GAL: Missing synced file(s): \${missingFiles.join(', ')}.\\nRun: gal sync --pull\`, 'missing_files');
258
+ }
259
+ }
260
+
261
+ // All good - show synced status
262
+ showMessage(\`✅ GAL: Synced with \${state.organization}'s approved config (v\${state.version || 'latest'})\`, 'synced');
263
+ `;
264
+
265
+ // =============================================================================
266
+ // Status Line Script Content
267
+ // =============================================================================
268
+ // Shows sync warnings in Claude Code's status bar (always when not synced).
269
+ // Silent when synced (no green tick spam).
270
+ // =============================================================================
271
+
272
+ const STATUS_LINE_CONTENT = `#!/usr/bin/env -S uv run --script
273
+ # /// script
274
+ # requires-python = ">=3.11"
275
+ # dependencies = [
276
+ # "python-dotenv",
277
+ # ]
278
+ # ///
279
+ """
280
+ GAL Sync Status Line for Claude Code
281
+ Generated by GAL CLI
282
+
283
+ Version: ${STATUS_LINE_VERSION}
284
+
285
+ Behavior:
286
+ - NOT synced: Always show warning
287
+ - Synced: Silent (no output)
288
+ """
289
+
290
+ # GAL_STATUS_LINE_VERSION = "${STATUS_LINE_VERSION}"
291
+
292
+ import json
293
+ import os
294
+ import sys
295
+ import subprocess
296
+ from pathlib import Path
297
+
298
+ # =============================================================================
299
+ # CONFIGURATION
300
+ # =============================================================================
301
+ GAL_DIR = '.gal'
302
+ SYNC_STATE_FILE = 'sync-state.json'
303
+ GAL_CONFIG_FILE = Path.home() / '.gal' / 'config.json'
304
+
305
+
306
+ def is_gal_installed() -> bool:
307
+ """Check if GAL CLI is installed."""
308
+ try:
309
+ subprocess.run(['which', 'gal'], capture_output=True, check=True)
310
+ return True
311
+ except (subprocess.CalledProcessError, FileNotFoundError):
312
+ return False
313
+
314
+
315
+ def self_clean():
316
+ """Remove this status line if GAL CLI is uninstalled."""
317
+ script_path = Path(__file__).resolve()
318
+ settings_path = Path.home() / '.claude' / 'settings.json'
319
+
320
+ # Remove script file
321
+ try:
322
+ script_path.unlink()
323
+ except (OSError, IOError):
324
+ pass
325
+
326
+ # Remove from settings.json
327
+ try:
328
+ if settings_path.exists():
329
+ settings = json.loads(settings_path.read_text())
330
+ status_line_cmd = settings.get('statusLine', {}).get('command', '')
331
+ if 'gal-sync-status' in status_line_cmd:
332
+ del settings['statusLine']
333
+ settings_path.write_text(json.dumps(settings, indent=2))
334
+ except (json.JSONDecodeError, IOError):
335
+ pass
336
+
337
+
338
+ def read_gal_config():
339
+ """Read GAL CLI config (auth token, default org)."""
340
+ if not GAL_CONFIG_FILE.exists():
341
+ return None
342
+ try:
343
+ return json.loads(GAL_CONFIG_FILE.read_text())
344
+ except (json.JSONDecodeError, IOError):
345
+ return None
346
+
347
+
348
+ def read_sync_state():
349
+ """Read sync state from .gal/sync-state.json in current directory."""
350
+ state_path = Path.cwd() / GAL_DIR / SYNC_STATE_FILE
351
+ if not state_path.exists():
352
+ return None
353
+ try:
354
+ return json.loads(state_path.read_text())
355
+ except (json.JSONDecodeError, IOError):
356
+ return None
357
+
358
+
359
+ def generate_status_line(input_data):
360
+ """Generate the GAL sync status line.
361
+
362
+ Behavior:
363
+ - NOT synced: Always show warning (no throttle)
364
+ - Synced: Silent (no message)
365
+ """
366
+
367
+ # Self-clean if GAL is uninstalled
368
+ if not is_gal_installed():
369
+ self_clean()
370
+ return ""
371
+
372
+ # Read GAL config
373
+ gal_config = read_gal_config()
374
+
375
+ # Check 1: Not authenticated - always show
376
+ if not gal_config or not gal_config.get('authToken'):
377
+ return "\\033[33m🔐 GAL: login\\033[0m"
378
+
379
+ # Check 2: Project not synced - always show
380
+ state = read_sync_state()
381
+
382
+ if not state:
383
+ return "\\033[33m📥 GAL: sync\\033[0m"
384
+
385
+ # Check 3: Config outdated (hash mismatch) - always show
386
+ if state.get('lastSyncHash') != state.get('approvedConfigHash'):
387
+ return "\\033[33m⚠️ GAL: outdated\\033[0m"
388
+
389
+ # Check 4: Missing synced files - always show
390
+ synced_files = state.get('syncedFiles', [])
391
+ if synced_files:
392
+ missing = [f for f in synced_files if not (Path.cwd() / f).exists()]
393
+ if missing:
394
+ return "\\033[33m⚠️ GAL: missing files\\033[0m"
395
+
396
+ # Synced - stay silent
397
+ return ""
398
+
399
+
400
+ def main():
401
+ try:
402
+ # Read JSON input from stdin (Claude Code passes context)
403
+ input_data = json.loads(sys.stdin.read())
404
+
405
+ # Generate status line
406
+ status_line = generate_status_line(input_data)
407
+
408
+ # Only output if there's something to show
409
+ if status_line:
410
+ print(status_line)
411
+
412
+ sys.exit(0)
413
+
414
+ except json.JSONDecodeError:
415
+ # Handle JSON decode errors gracefully - stay silent
416
+ sys.exit(0)
417
+ except Exception:
418
+ # Handle any other errors gracefully - stay silent
419
+ sys.exit(0)
420
+
421
+
422
+ if __name__ == '__main__':
423
+ main()
424
+ `;
425
+
426
+ // =============================================================================
427
+ // Installation Functions
428
+ // =============================================================================
429
+
430
+ /**
431
+ * Install the SessionStart hook to ~/.claude/hooks/
432
+ *
433
+ * Key behaviors:
434
+ * - Idempotent: Checks version before writing
435
+ * - Cleans up old UserPromptSubmit hooks
436
+ * - Registers in settings.json
437
+ */
438
+ function installHook() {
439
+ const claudeDir = path.join(os.homedir(), '.claude');
440
+ const hooksDir = path.join(claudeDir, 'hooks');
441
+ const hookPath = path.join(hooksDir, 'gal-sync-reminder.js');
442
+ const settingsPath = path.join(claudeDir, 'settings.json');
443
+
444
+ try {
445
+ // Create directories if needed
446
+ if (!fs.existsSync(hooksDir)) {
447
+ fs.mkdirSync(hooksDir, { recursive: true });
448
+ }
449
+
450
+ // Check if hook already exists with current version
451
+ let needsUpdate = true;
452
+ if (fs.existsSync(hookPath)) {
453
+ const existingContent = fs.readFileSync(hookPath, 'utf-8');
454
+ const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
455
+ if (versionMatch && versionMatch[1] === HOOK_VERSION) {
456
+ needsUpdate = false;
457
+ }
458
+ }
459
+
460
+ // Write the hook file if needed
461
+ if (needsUpdate) {
462
+ fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf-8');
463
+ fs.chmodSync(hookPath, '755');
464
+ }
465
+
466
+ // Update settings.json
467
+ let settings = {};
468
+ if (fs.existsSync(settingsPath)) {
469
+ try {
470
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
471
+ } catch {
472
+ settings = {};
473
+ }
474
+ }
475
+
476
+ // CLEANUP: Remove old UserPromptSubmit hooks (we now use SessionStart)
477
+ if (settings.hooks?.UserPromptSubmit) {
478
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(entry => {
479
+ if (!entry.hooks) return true;
480
+ entry.hooks = entry.hooks.filter(h =>
481
+ !h.command?.includes('gal-') && !h.command?.includes('/gal/')
482
+ );
483
+ return entry.hooks.length > 0;
484
+ });
485
+ if (settings.hooks.UserPromptSubmit.length === 0) {
486
+ delete settings.hooks.UserPromptSubmit;
487
+ }
488
+ }
489
+
490
+ // Register SessionStart hook if not already registered
491
+ const hookCommand = `node ${hookPath}`;
492
+ if (!settings.hooks) settings.hooks = {};
493
+ if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
494
+
495
+ const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
496
+ entry.hooks?.some(h => h.command?.includes('gal-sync-reminder'))
497
+ );
498
+
499
+ if (!alreadyRegistered) {
500
+ settings.hooks.SessionStart.push({
501
+ hooks: [{ type: 'command', command: hookCommand }]
502
+ });
503
+ }
504
+
505
+ // Write settings
506
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
507
+
508
+ if (needsUpdate) {
509
+ console.log('✓ GAL SessionStart hook installed');
510
+ }
511
+ return true;
512
+ } catch (error) {
513
+ // Silent fail - hook is optional enhancement
514
+ return false;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Install GAL CLI rules to ~/.claude/rules/
520
+ *
521
+ * Key behaviors:
522
+ * - Idempotent: Checks version before writing
523
+ * - Provides persistent GAL CLI awareness
524
+ * - No longer runs on every prompt (unlike hooks)
525
+ */
526
+ function installRules() {
527
+ const claudeDir = path.join(os.homedir(), '.claude');
528
+ const rulesDir = path.join(claudeDir, 'rules');
529
+ const rulesPath = path.join(rulesDir, 'gal-cli.md');
530
+
531
+ try {
532
+ // Create rules directory if needed
533
+ if (!fs.existsSync(rulesDir)) {
534
+ fs.mkdirSync(rulesDir, { recursive: true });
535
+ }
536
+
537
+ // Check if rules file already exists with current version
538
+ let needsUpdate = true;
539
+ if (fs.existsSync(rulesPath)) {
540
+ const existingContent = fs.readFileSync(rulesPath, 'utf-8');
541
+ const versionMatch = existingContent.match(/GAL_RULES_VERSION = "([^"]+)"/);
542
+ if (versionMatch && versionMatch[1] === RULES_VERSION) {
543
+ needsUpdate = false;
544
+ }
545
+ }
546
+
547
+ // Write the rules file if needed
548
+ if (needsUpdate) {
549
+ fs.writeFileSync(rulesPath, GAL_CLI_RULES_CONTENT, 'utf-8');
550
+ console.log('✓ GAL CLI rules installed');
551
+ }
552
+
553
+ return true;
554
+ } catch (error) {
555
+ // Silent fail - rules are optional enhancement
556
+ return false;
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Queue a telemetry event for the next CLI run (GAL-114)
562
+ * Since postinstall is CommonJS and telemetry module is ESM,
563
+ * we write events to a pending file that the CLI picks up on startup
564
+ */
565
+ function queueTelemetryEvent() {
566
+ const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
567
+ const galDir = path.join(os.homedir(), '.gal');
568
+
569
+ let pending = [];
570
+ try {
571
+ if (fs.existsSync(pendingEventsPath)) {
572
+ pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
573
+ }
574
+ } catch {}
575
+
576
+ // Add postinstall hook event
577
+ pending.push({
578
+ id: require('crypto').randomUUID(),
579
+ eventType: 'hook_triggered',
580
+ timestamp: new Date().toISOString(),
581
+ payload: {
582
+ notificationType: 'postinstall',
583
+ cliVersion: CLI_VERSION,
584
+ platform: process.platform,
585
+ nodeVersion: process.version,
586
+ },
587
+ queuedAt: Date.now(),
588
+ });
589
+
590
+ try {
591
+ if (!fs.existsSync(galDir)) {
592
+ fs.mkdirSync(galDir, { recursive: true });
593
+ }
594
+ fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
595
+ } catch {
596
+ // Ignore errors - telemetry is optional
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Install the status line script to ~/.claude/status_lines/
602
+ *
603
+ * Key behaviors:
604
+ * - Idempotent: Checks version before writing
605
+ * - Won't overwrite user's custom statusLine
606
+ * - Registers in settings.json
607
+ */
608
+ function installStatusLine() {
609
+ const claudeDir = path.join(os.homedir(), '.claude');
610
+ const statusLinesDir = path.join(claudeDir, 'status_lines');
611
+ const scriptPath = path.join(statusLinesDir, 'gal-sync-status.py');
612
+ const settingsPath = path.join(claudeDir, 'settings.json');
613
+
614
+ try {
615
+ // Check existing settings for custom statusLine
616
+ let settings = {};
617
+ if (fs.existsSync(settingsPath)) {
618
+ try {
619
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
620
+ } catch {
621
+ settings = {};
622
+ }
623
+ }
624
+
625
+ // Don't overwrite user's custom statusLine
626
+ if (settings.statusLine?.command && !settings.statusLine.command.includes('gal-sync-status')) {
627
+ console.log('ℹ Custom statusLine detected, skipping GAL status line');
628
+ return false;
629
+ }
630
+
631
+ // Create directories if needed
632
+ if (!fs.existsSync(statusLinesDir)) {
633
+ fs.mkdirSync(statusLinesDir, { recursive: true });
634
+ }
635
+
636
+ // Check if script already exists with current version
637
+ let needsUpdate = true;
638
+ if (fs.existsSync(scriptPath)) {
639
+ const existingContent = fs.readFileSync(scriptPath, 'utf-8');
640
+ const versionMatch = existingContent.match(/GAL_STATUS_LINE_VERSION = "([^"]+)"/);
641
+ if (versionMatch && versionMatch[1] === STATUS_LINE_VERSION) {
642
+ // Also check if it's registered in settings
643
+ if (settings.statusLine?.command?.includes('gal-sync-status')) {
644
+ needsUpdate = false;
645
+ }
646
+ }
647
+ }
648
+
649
+ // Write the script file if needed
650
+ if (needsUpdate) {
651
+ fs.writeFileSync(scriptPath, STATUS_LINE_CONTENT, 'utf-8');
652
+ fs.chmodSync(scriptPath, '755');
653
+
654
+ // Register in settings.json
655
+ settings.statusLine = {
656
+ type: 'command',
657
+ command: scriptPath
658
+ };
659
+
660
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
661
+ console.log('✓ GAL status line installed');
662
+ }
663
+
664
+ return true;
665
+ } catch (error) {
666
+ // Silent fail - status line is optional enhancement
667
+ return false;
668
+ }
669
+ }
670
+
671
+ // =============================================================================
672
+ // Main
673
+ // =============================================================================
674
+
675
+ function main() {
676
+ const hookInstalled = installHook();
677
+ const rulesInstalled = installRules();
678
+ const statusLineInstalled = installStatusLine();
679
+
680
+ // Queue telemetry event (GAL-114)
681
+ queueTelemetryEvent();
682
+
683
+ if (hookInstalled || rulesInstalled || statusLineInstalled) {
684
+ console.log('');
685
+ console.log('Restart Claude Code/Cursor for changes to take effect.');
686
+ console.log('');
687
+ console.log('Next steps:');
688
+ console.log(' 1. gal auth login - Authenticate with GitHub');
689
+ console.log(' 2. gal sync --pull - Download org-approved config');
690
+ console.log('');
691
+ }
692
+ }
693
+
694
+ main();