@scheduler-systems/gal-run 0.0.529 → 0.0.534

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.
@@ -1,1147 +0,0 @@
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.2.0'; // SessionStart + Stop hooks with local usage reporting (#4673)
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
- const crypto = require('crypto');
118
-
119
- const GAL_DIR = '.gal';
120
- const SYNC_STATE_FILE = 'sync-state.json';
121
- const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
122
- const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
123
-
124
- function showMessage(message, status) {
125
- // Queue telemetry event before showing message
126
- queueTelemetryEvent(status);
127
- console.log(JSON.stringify({ systemMessage: message }));
128
- process.exit(0);
129
- }
130
-
131
- // =============================================================================
132
- // Telemetry: Queue events for CLI to send on next run (GAL-114)
133
- // =============================================================================
134
- // Hook runs in Claude context (no network access), so we queue telemetry events
135
- // in a JSON file that the CLI reads and flushes on next run. This allows us to
136
- // track hook executions without blocking the user or requiring network calls.
137
- // =============================================================================
138
-
139
- /**
140
- * Queue a telemetry event for the next CLI run.
141
- *
142
- * Since hooks run synchronously in Claude's context, we can't send telemetry
143
- * directly. Instead, we write events to a pending queue file that the CLI
144
- * reads and flushes on its next execution.
145
- *
146
- * @param {string} status - Sync status from the hook (auth_required, synced, etc.)
147
- */
148
- function queueTelemetryEvent(status) {
149
- const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
150
- const galDir = path.join(os.homedir(), '.gal');
151
-
152
- let pending = [];
153
- try {
154
- if (fs.existsSync(pendingEventsPath)) {
155
- pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
156
- }
157
- } catch {}
158
-
159
- // Add session start hook event
160
- pending.push({
161
- id: crypto.randomUUID(),
162
- eventType: 'hook_triggered',
163
- timestamp: new Date().toISOString(),
164
- payload: {
165
- notificationType: 'session_start',
166
- hookVersion: '${HOOK_VERSION}',
167
- status: status,
168
- cwd: process.cwd(),
169
- platform: process.platform,
170
- nodeVersion: process.version,
171
- },
172
- queuedAt: Date.now(),
173
- });
174
-
175
- try {
176
- if (!fs.existsSync(galDir)) {
177
- fs.mkdirSync(galDir, { recursive: true });
178
- }
179
- fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
180
- } catch {
181
- // Ignore errors - telemetry is optional
182
- }
183
- }
184
-
185
- function readHookInput() {
186
- try {
187
- if (process.stdin.isTTY) return {};
188
- const raw = fs.readFileSync(0, 'utf-8');
189
- if (!raw || !raw.trim()) return {};
190
- return JSON.parse(raw);
191
- } catch {
192
- return {};
193
- }
194
- }
195
-
196
- function getSessionUsagePath(inputData) {
197
- const identity =
198
- (typeof inputData.session_id === 'string' && inputData.session_id) ||
199
- (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
200
- process.cwd();
201
- const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
202
- return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
203
- }
204
-
205
- function rememberSessionStart(inputData) {
206
- try {
207
- const filePath = getSessionUsagePath(inputData);
208
- if (!fs.existsSync(SESSION_USAGE_DIR)) {
209
- fs.mkdirSync(SESSION_USAGE_DIR, { recursive: true });
210
- }
211
- fs.writeFileSync(
212
- filePath,
213
- JSON.stringify({
214
- sessionId: inputData.session_id || null,
215
- transcriptPath: inputData.transcript_path || null,
216
- source: inputData.source || null,
217
- startedAt: new Date().toISOString(),
218
- }),
219
- 'utf-8',
220
- );
221
- } catch {}
222
- }
223
-
224
- // =============================================================================
225
- // Self-cleaning: Remove hook if GAL CLI is uninstalled
226
- // =============================================================================
227
-
228
- function isGalInstalled() {
229
- try {
230
- execSync('which gal', { stdio: 'ignore' });
231
- return true;
232
- } catch {
233
- return false;
234
- }
235
- }
236
-
237
- function selfClean() {
238
- const hookPath = __filename;
239
- const claudeDir = path.join(os.homedir(), '.claude');
240
- const settingsPath = path.join(claudeDir, 'settings.json');
241
- const rulesPath = path.join(claudeDir, 'rules', 'gal-cli.md');
242
-
243
- // Remove hook file
244
- try { fs.unlinkSync(hookPath); } catch {}
245
-
246
- // Remove rules file
247
- try { fs.unlinkSync(rulesPath); } catch {}
248
-
249
- // Remove hook entries from settings.json
250
- try {
251
- if (fs.existsSync(settingsPath)) {
252
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
253
- const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
254
-
255
- for (const event of hookEvents) {
256
- if (settings.hooks?.[event]) {
257
- settings.hooks[event] = settings.hooks[event].filter(entry => {
258
- if (!entry.hooks) return true;
259
- entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
260
- return entry.hooks.length > 0;
261
- });
262
- if (settings.hooks[event].length === 0) delete settings.hooks[event];
263
- }
264
- }
265
-
266
- if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
267
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
268
- }
269
- } catch {}
270
- }
271
-
272
- const hookInput = readHookInput();
273
- rememberSessionStart(hookInput);
274
-
275
- // Check if GAL is installed, self-clean if not
276
- if (!isGalInstalled()) {
277
- selfClean();
278
- process.exit(0);
279
- }
280
-
281
- // Read GAL CLI config (auth token, default org)
282
- function readGalConfig() {
283
- if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
284
- try {
285
- return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
286
- } catch { return null; }
287
- }
288
-
289
- // Decode JWT without verification (just to check expiration)
290
- function decodeJwt(token) {
291
- try {
292
- const parts = token.split('.');
293
- if (parts.length !== 3) return null;
294
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
295
- return payload;
296
- } catch { return null; }
297
- }
298
-
299
- // =============================================================================
300
- // Auto-update check: run gal update if a newer version is cached
301
- // =============================================================================
302
- function checkAndAutoUpdate() {
303
- const updateCachePath = path.join(os.homedir(), '.gal', 'update-cache.json');
304
- if (!fs.existsSync(updateCachePath)) return null;
305
- try {
306
- const cache = JSON.parse(fs.readFileSync(updateCachePath, 'utf-8'));
307
- if (!cache.latestVersion) return null;
308
- let currentVersion;
309
- try {
310
- currentVersion = execSync('gal --version', { stdio: 'pipe', timeout: 5000 }).toString().trim();
311
- } catch { return null; }
312
- const cv = currentVersion.replace(/^v/, '').split('.').map(Number);
313
- const lv = cache.latestVersion.replace(/^v/, '').split('.').map(Number);
314
- let needsUpdate = false;
315
- for (let i = 0; i < 3; i++) {
316
- if ((cv[i] || 0) < (lv[i] || 0)) { needsUpdate = true; break; }
317
- if ((cv[i] || 0) > (lv[i] || 0)) break;
318
- }
319
- if (!needsUpdate) return null;
320
- if (process.env.GAL_NO_AUTO_UPDATE === '1' || process.env.CI) return null;
321
- try {
322
- execSync('gal update', { stdio: 'pipe', timeout: 30000 });
323
- return cache.latestVersion;
324
- } catch { return null; }
325
- } catch { return null; }
326
- }
327
-
328
- // Refresh update cache in background if stale (>24h)
329
- function refreshUpdateCacheIfStale() {
330
- try {
331
- const updateCachePath = path.join(os.homedir(), '.gal', 'update-cache.json');
332
- let needsRefresh = true;
333
- if (fs.existsSync(updateCachePath)) {
334
- try {
335
- const cache = JSON.parse(fs.readFileSync(updateCachePath, 'utf-8'));
336
- if (cache.lastCheck && (Date.now() - cache.lastCheck) < 24 * 60 * 60 * 1000) {
337
- needsRefresh = false;
338
- }
339
- } catch {}
340
- }
341
- if (needsRefresh) {
342
- const child = spawn('gal', ['update', '--check'], {
343
- stdio: 'ignore',
344
- detached: true,
345
- });
346
- child.unref();
347
- }
348
- } catch {}
349
- }
350
-
351
- // Fallback repair: reinstall via native installer if binary is broken (#4369)
352
- function repairBrokenBinary() {
353
- if (process.env.GAL_NO_AUTO_UPDATE === '1' || process.env.CI) return false;
354
- try {
355
- execSync('gal --version', { stdio: 'pipe', timeout: 5000 });
356
- return false;
357
- } catch {
358
- try {
359
- execSync('curl -fsSL https://gal.run/install.sh | sh -s -- --force', {
360
- stdio: 'pipe', timeout: 60000, shell: true,
361
- });
362
- execSync('gal --version', { stdio: 'pipe', timeout: 5000 });
363
- return true;
364
- } catch { return false; }
365
- }
366
- }
367
-
368
- const wasRepaired = repairBrokenBinary();
369
- const updatedVersion = wasRepaired ? 'repaired' : checkAndAutoUpdate();
370
- refreshUpdateCacheIfStale();
371
-
372
- // Check authentication status
373
- const galConfig = readGalConfig();
374
-
375
- // Check 1: Not authenticated
376
- if (!galConfig || !galConfig.authToken) {
377
- showMessage("🔐 GAL: Authentication required.\\nRun: gal auth login", 'auth_required');
378
- }
379
-
380
- // Check 2: Token expired
381
- const tokenPayload = decodeJwt(galConfig.authToken);
382
- if (tokenPayload && tokenPayload.exp) {
383
- const expiresAt = tokenPayload.exp * 1000;
384
- if (Date.now() > expiresAt) {
385
- showMessage("🔐 GAL: Session expired.\\nRun: gal auth login", 'token_expired');
386
- }
387
- }
388
-
389
- // Check 3: Project not synced
390
- function readSyncState() {
391
- const statePath = path.join(process.cwd(), GAL_DIR, SYNC_STATE_FILE);
392
- if (!fs.existsSync(statePath)) return null;
393
- try {
394
- return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
395
- } catch { return null; }
396
- }
397
-
398
- let state = readSyncState();
399
-
400
- if (!state) {
401
- // Attempt auto-sync
402
- try {
403
- execSync('gal sync --pull --auto', { stdio: 'pipe', timeout: 30000 });
404
- state = readSyncState();
405
- } catch {}
406
-
407
- if (!state) {
408
- const orgName = galConfig.defaultOrg || 'your organization';
409
- showMessage(\`📥 GAL: Not synced with \${orgName}'s approved config.\\nRun: gal sync --pull\`, 'not_synced');
410
- }
411
- }
412
-
413
- // Check 4: Config outdated
414
- if (state && state.lastSyncHash !== state.approvedConfigHash) {
415
- // Attempt auto-sync for outdated configs
416
- try {
417
- execSync('gal sync --pull --auto', { stdio: 'pipe', timeout: 30000 });
418
- state = readSyncState();
419
- } catch {}
420
-
421
- if (state && state.lastSyncHash !== state.approvedConfigHash) {
422
- const days = Math.floor((Date.now() - new Date(state.lastSyncTimestamp).getTime()) / (24 * 60 * 60 * 1000));
423
- showMessage(\`⚠️ GAL: Config is \${days} day(s) behind \${state.organization}'s approved version.\\nRun: gal sync --pull\`, 'config_outdated');
424
- }
425
- }
426
-
427
- // Check 5: Missing synced files
428
- if (state && state.syncedFiles && state.syncedFiles.length > 0) {
429
- const missingFiles = state.syncedFiles.filter(f => {
430
- const fullPath = path.join(process.cwd(), f);
431
- return !fs.existsSync(fullPath);
432
- });
433
-
434
- if (missingFiles.length > 0) {
435
- showMessage(\`⚠️ GAL: Missing synced file(s): \${missingFiles.join(', ')}.\\nRun: gal sync --pull\`, 'missing_files');
436
- }
437
- }
438
-
439
- // All good - build synced status message with optional dispatch rules
440
- if (!state) {
441
- showMessage("✅ GAL: Ready", 'synced');
442
- }
443
-
444
- let syncMessage = \`✅ GAL: Synced with \${state.organization}'s approved config (v\${state.version || 'latest'})\${state.policyName ? \` — Policy: \\\`\${state.policyName}\\\`\` : ''}\`;
445
-
446
- if (updatedVersion) {
447
- syncMessage = \`🔄 GAL: Updated to v\${updatedVersion}. \` + syncMessage;
448
- }
449
-
450
- // Inject dispatch rules summary if available
451
- try {
452
- const dispatchPath = path.join(process.cwd(), '.gal', 'dispatch-rules.json');
453
- if (fs.existsSync(dispatchPath)) {
454
- const rules = JSON.parse(fs.readFileSync(dispatchPath, 'utf-8'));
455
- if (rules.enabled && rules.categories) {
456
- const eligible = rules.categories.filter(c => c.enabled).map(c => c.name);
457
- const local = rules.categories.filter(c => !c.enabled).map(c => c.name);
458
- if (eligible.length > 0) {
459
- syncMessage += \`\\n📋 Background dispatch: \${eligible.join(', ')} → use \\\`gal dispatch\\\`. \${local.length > 0 ? local.join(', ') + ' → keep local.' : ''}\`;
460
- }
461
- }
462
- }
463
- } catch {
464
- // Dispatch rules are optional - ignore errors
465
- }
466
-
467
- showMessage(syncMessage, 'synced');
468
- `;
469
-
470
- const STOP_HOOK_CONTENT = `#!/usr/bin/env node
471
- /**
472
- * GAL Local Usage Report Hook for Claude Code (Stop)
473
- * Version: ${HOOK_VERSION}
474
- */
475
-
476
- // GAL_HOOK_VERSION = "${HOOK_VERSION}"
477
-
478
- const fs = require('fs');
479
- const path = require('path');
480
- const os = require('os');
481
- const crypto = require('crypto');
482
- const { execSync } = require('child_process');
483
-
484
- const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
485
- const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
486
-
487
- function readHookInput() {
488
- try {
489
- if (process.stdin.isTTY) return {};
490
- const raw = fs.readFileSync(0, 'utf-8');
491
- if (!raw || !raw.trim()) return {};
492
- return JSON.parse(raw);
493
- } catch {
494
- return {};
495
- }
496
- }
497
-
498
- function getSessionUsagePath(inputData) {
499
- const identity =
500
- (typeof inputData.session_id === 'string' && inputData.session_id) ||
501
- (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
502
- process.cwd();
503
- const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
504
- return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
505
- }
506
-
507
- function readGalConfig() {
508
- if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
509
- try {
510
- return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
511
- } catch { return null; }
512
- }
513
-
514
- function isGalInstalled() {
515
- try {
516
- execSync('which gal', { stdio: 'ignore' });
517
- return true;
518
- } catch {
519
- return false;
520
- }
521
- }
522
-
523
- function selfClean() {
524
- const hookPath = __filename;
525
- const claudeDir = path.join(os.homedir(), '.claude');
526
- const settingsPath = path.join(claudeDir, 'settings.json');
527
-
528
- try { fs.unlinkSync(hookPath); } catch {}
529
-
530
- try {
531
- if (fs.existsSync(settingsPath)) {
532
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
533
- const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
534
-
535
- for (const event of hookEvents) {
536
- if (settings.hooks?.[event]) {
537
- settings.hooks[event] = settings.hooks[event].filter(entry => {
538
- if (!entry.hooks) return true;
539
- entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
540
- return entry.hooks.length > 0;
541
- });
542
- if (settings.hooks[event].length === 0) delete settings.hooks[event];
543
- }
544
- }
545
-
546
- if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
547
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
548
- }
549
- } catch {}
550
- }
551
-
552
- if (!isGalInstalled()) {
553
- selfClean();
554
- process.exit(0);
555
- }
556
-
557
- const hookInput = readHookInput();
558
- const usagePath = getSessionUsagePath(hookInput);
559
-
560
- if (!fs.existsSync(usagePath)) {
561
- process.exit(0);
562
- }
563
-
564
- let startedAt = null;
565
- try {
566
- const sessionState = JSON.parse(fs.readFileSync(usagePath, 'utf-8'));
567
- startedAt = sessionState.startedAt || null;
568
- } catch {}
569
-
570
- try {
571
- fs.unlinkSync(usagePath);
572
- } catch {}
573
-
574
- if (!startedAt) {
575
- process.exit(0);
576
- }
577
-
578
- const startedMs = new Date(startedAt).getTime();
579
- if (!Number.isFinite(startedMs) || startedMs <= 0) {
580
- process.exit(0);
581
- }
582
-
583
- const durationSeconds = Math.max(1, Math.round((Date.now() - startedMs) / 1000));
584
- const galConfig = readGalConfig();
585
- const orgName = galConfig?.defaultOrg;
586
-
587
- if (!orgName) {
588
- process.exit(0);
589
- }
590
-
591
- try {
592
- execSync(
593
- \`gal report-usage --provider claude --seconds \${durationSeconds} --org \${JSON.stringify(orgName)} --json\`,
594
- { stdio: 'pipe', timeout: 15000, shell: true },
595
- );
596
- } catch {}
597
-
598
- process.exit(0);
599
- `;
600
-
601
- // =============================================================================
602
- // Status Line Script Content
603
- // =============================================================================
604
- // Python script that runs continuously in Claude's status bar.
605
- // Shows warnings when not synced, silent when synced (avoids status bar spam).
606
- // Uses uv's inline script runner for dependency management.
607
- // =============================================================================
608
-
609
- const STATUS_LINE_CONTENT = `#!/usr/bin/env -S uv run --script
610
- # /// script
611
- # requires-python = ">=3.11"
612
- # dependencies = [
613
- # "python-dotenv",
614
- # ]
615
- # ///
616
- """
617
- GAL Sync Status Line for Claude Code
618
- Generated by GAL CLI
619
-
620
- Version: ${STATUS_LINE_VERSION}
621
-
622
- Behavior:
623
- - NOT synced: Always show warning
624
- - Synced: Silent (no output)
625
- """
626
-
627
- # GAL_STATUS_LINE_VERSION = "${STATUS_LINE_VERSION}"
628
-
629
- import json
630
- import os
631
- import sys
632
- import subprocess
633
- from pathlib import Path
634
-
635
- # =============================================================================
636
- # CONFIGURATION
637
- # =============================================================================
638
- GAL_DIR = '.gal'
639
- SYNC_STATE_FILE = 'sync-state.json'
640
- GAL_CONFIG_FILE = Path.home() / '.gal' / 'config.json'
641
-
642
-
643
- def is_gal_installed() -> bool:
644
- """Check if GAL CLI is installed."""
645
- try:
646
- subprocess.run(['which', 'gal'], capture_output=True, check=True)
647
- return True
648
- except (subprocess.CalledProcessError, FileNotFoundError):
649
- return False
650
-
651
-
652
- def self_clean():
653
- """Remove this status line if GAL CLI is uninstalled."""
654
- script_path = Path(__file__).resolve()
655
- settings_path = Path.home() / '.claude' / 'settings.json'
656
-
657
- # Remove script file
658
- try:
659
- script_path.unlink()
660
- except (OSError, IOError):
661
- pass
662
-
663
- # Remove from settings.json
664
- try:
665
- if settings_path.exists():
666
- settings = json.loads(settings_path.read_text())
667
- status_line_cmd = settings.get('statusLine', {}).get('command', '')
668
- if 'gal-sync-status' in status_line_cmd:
669
- del settings['statusLine']
670
- settings_path.write_text(json.dumps(settings, indent=2))
671
- except (json.JSONDecodeError, IOError):
672
- pass
673
-
674
-
675
- def read_gal_config():
676
- """Read GAL CLI config (auth token, default org)."""
677
- if not GAL_CONFIG_FILE.exists():
678
- return None
679
- try:
680
- return json.loads(GAL_CONFIG_FILE.read_text())
681
- except (json.JSONDecodeError, IOError):
682
- return None
683
-
684
-
685
- def read_sync_state():
686
- """Read sync state from .gal/sync-state.json in current directory."""
687
- state_path = Path.cwd() / GAL_DIR / SYNC_STATE_FILE
688
- if not state_path.exists():
689
- return None
690
- try:
691
- return json.loads(state_path.read_text())
692
- except (json.JSONDecodeError, IOError):
693
- return None
694
-
695
-
696
- def generate_status_line(input_data):
697
- """Generate the GAL sync status line.
698
-
699
- Behavior:
700
- - NOT synced: Always show warning (no throttle)
701
- - Synced: Silent (no message)
702
- """
703
-
704
- # Self-clean if GAL is uninstalled
705
- if not is_gal_installed():
706
- self_clean()
707
- return ""
708
-
709
- # Read GAL config
710
- gal_config = read_gal_config()
711
-
712
- # Check 1: Not authenticated - always show
713
- if not gal_config or not gal_config.get('authToken'):
714
- return "\\033[33m🔐 GAL: login\\033[0m"
715
-
716
- # Check 2: Project not synced - always show
717
- state = read_sync_state()
718
-
719
- if not state:
720
- return "\\033[33m📥 GAL: sync\\033[0m"
721
-
722
- # Check 3: Config outdated (hash mismatch) - always show
723
- if state.get('lastSyncHash') != state.get('approvedConfigHash'):
724
- return "\\033[33m⚠️ GAL: outdated\\033[0m"
725
-
726
- # Check 4: Missing synced files - always show
727
- synced_files = state.get('syncedFiles', [])
728
- if synced_files:
729
- missing = [f for f in synced_files if not (Path.cwd() / f).exists()]
730
- if missing:
731
- return "\\033[33m⚠️ GAL: missing files\\033[0m"
732
-
733
- # Synced - stay silent
734
- return ""
735
-
736
-
737
- def main():
738
- try:
739
- # Read JSON input from stdin (Claude Code passes context)
740
- input_data = json.loads(sys.stdin.read())
741
-
742
- # Generate status line
743
- status_line = generate_status_line(input_data)
744
-
745
- # Only output if there's something to show
746
- if status_line:
747
- print(status_line)
748
-
749
- sys.exit(0)
750
-
751
- except json.JSONDecodeError:
752
- # Handle JSON decode errors gracefully - stay silent
753
- sys.exit(0)
754
- except Exception:
755
- # Handle any other errors gracefully - stay silent
756
- sys.exit(0)
757
-
758
-
759
- if __name__ == '__main__':
760
- main()
761
- `;
762
-
763
- // =============================================================================
764
- // Installation Functions
765
- // =============================================================================
766
-
767
- // =============================================================================
768
- // Installation Functions - SessionStart Hook
769
- // =============================================================================
770
-
771
- /**
772
- * Install the SessionStart hook to ~/.claude/hooks/gal-sync-reminder.js
773
- *
774
- * The hook shows sync status at the start of each Claude session, prompting
775
- * users to login or sync if needed. It checks:
776
- * 1. Authentication status (GAL CLI login)
777
- * 2. Project sync state (gal sync --pull)
778
- * 3. Config staleness (hash mismatch)
779
- * 4. Missing synced files
780
- *
781
- * Key behaviors:
782
- * - Idempotent: Checks HOOK_VERSION marker before writing
783
- * - Migration: Cleans up old UserPromptSubmit hooks (v1.x used those)
784
- * - Registration: Adds hook to ~/.claude/settings.json
785
- * - Self-cleaning: Hook removes itself if GAL CLI is uninstalled
786
- *
787
- * @returns {boolean} True if hook was installed or updated, false on error
788
- */
789
- function installHook() {
790
- const claudeDir = path.join(os.homedir(), '.claude');
791
- const hooksDir = path.join(claudeDir, 'hooks');
792
- const hookPath = path.join(hooksDir, 'gal-sync-reminder.js');
793
- const stopHookPath = path.join(hooksDir, 'gal-usage-report.js');
794
- const settingsPath = path.join(claudeDir, 'settings.json');
795
-
796
- try {
797
- // Create directories if needed
798
- if (!fs.existsSync(hooksDir)) {
799
- fs.mkdirSync(hooksDir, { recursive: true });
800
- }
801
-
802
- // Check if hook already exists with current version
803
- let needsUpdate = true;
804
- if (fs.existsSync(hookPath)) {
805
- const existingContent = fs.readFileSync(hookPath, 'utf-8');
806
- const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
807
- if (versionMatch && versionMatch[1] === HOOK_VERSION) {
808
- needsUpdate = false;
809
- }
810
- }
811
- let needsStopUpdate = true;
812
- if (fs.existsSync(stopHookPath)) {
813
- const existingContent = fs.readFileSync(stopHookPath, 'utf-8');
814
- const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
815
- if (versionMatch && versionMatch[1] === HOOK_VERSION) {
816
- needsStopUpdate = false;
817
- }
818
- }
819
-
820
- // Write the hook file if needed
821
- if (needsUpdate) {
822
- fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf-8');
823
- fs.chmodSync(hookPath, '755');
824
- }
825
- if (needsStopUpdate) {
826
- fs.writeFileSync(stopHookPath, STOP_HOOK_CONTENT, 'utf-8');
827
- fs.chmodSync(stopHookPath, '755');
828
- }
829
-
830
- // Update settings.json
831
- let settings = {};
832
- if (fs.existsSync(settingsPath)) {
833
- try {
834
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
835
- } catch {
836
- settings = {};
837
- }
838
- }
839
-
840
- // CLEANUP: Remove old UserPromptSubmit hooks (v1.x migration)
841
- // GAL CLI v1.x used UserPromptSubmit hooks, but they caused performance issues
842
- // by running on every user message. v2.x uses SessionStart hooks instead.
843
- if (settings.hooks?.UserPromptSubmit) {
844
- settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(entry => {
845
- if (!entry.hooks) return true;
846
- entry.hooks = entry.hooks.filter(h =>
847
- !h.command?.includes('gal-') && !h.command?.includes('/gal/')
848
- );
849
- return entry.hooks.length > 0;
850
- });
851
- if (settings.hooks.UserPromptSubmit.length === 0) {
852
- delete settings.hooks.UserPromptSubmit;
853
- }
854
- }
855
-
856
- // Register SessionStart hook if not already registered
857
- const hookCommand = `node ${hookPath}`;
858
- if (!settings.hooks) settings.hooks = {};
859
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
860
- if (!settings.hooks.Stop) settings.hooks.Stop = [];
861
-
862
- const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
863
- entry.hooks?.some(h => h.command?.includes('gal-sync-reminder'))
864
- );
865
- const stopAlreadyRegistered = settings.hooks.Stop.some(entry =>
866
- entry.hooks?.some(h => h.command?.includes('gal-usage-report'))
867
- );
868
-
869
- if (!alreadyRegistered) {
870
- settings.hooks.SessionStart.push({
871
- hooks: [{ type: 'command', command: hookCommand }]
872
- });
873
- }
874
- if (!stopAlreadyRegistered) {
875
- settings.hooks.Stop.push({
876
- hooks: [{ type: 'command', command: `node ${stopHookPath}` }]
877
- });
878
- }
879
-
880
- // Write settings
881
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
882
-
883
- if (needsUpdate || needsStopUpdate) {
884
- console.log('✓ GAL Claude hooks installed');
885
- }
886
- return true;
887
- } catch (error) {
888
- // Silent fail - hook is optional enhancement
889
- return false;
890
- }
891
- }
892
-
893
- // =============================================================================
894
- // Installation Functions - GAL CLI Rules
895
- // =============================================================================
896
-
897
- /**
898
- * Install GAL CLI rules to ~/.claude/rules/gal-cli.md
899
- *
900
- * Rules provide persistent awareness of GAL CLI commands without hook overhead.
901
- * Claude automatically loads rules from ~/.claude/rules/ at session start,
902
- * so the AI knows about `gal` commands and can suggest their usage.
903
- *
904
- * Key behaviors:
905
- * - Idempotent: Checks RULES_VERSION marker before writing
906
- * - Lightweight: No runtime overhead (unlike hooks that execute on events)
907
- * - Persistent: Remains loaded for entire Claude session
908
- *
909
- * @returns {boolean} True if rules were installed or updated, false on error
910
- */
911
- function installRules() {
912
- const claudeDir = path.join(os.homedir(), '.claude');
913
- const rulesDir = path.join(claudeDir, 'rules');
914
- const rulesPath = path.join(rulesDir, 'gal-cli.md');
915
-
916
- try {
917
- // Create rules directory if needed
918
- if (!fs.existsSync(rulesDir)) {
919
- fs.mkdirSync(rulesDir, { recursive: true });
920
- }
921
-
922
- // Check if rules file already exists with current version
923
- let needsUpdate = true;
924
- if (fs.existsSync(rulesPath)) {
925
- const existingContent = fs.readFileSync(rulesPath, 'utf-8');
926
- const versionMatch = existingContent.match(/GAL_RULES_VERSION = "([^"]+)"/);
927
- if (versionMatch && versionMatch[1] === RULES_VERSION) {
928
- needsUpdate = false;
929
- }
930
- }
931
-
932
- // Write the rules file if needed
933
- if (needsUpdate) {
934
- fs.writeFileSync(rulesPath, GAL_CLI_RULES_CONTENT, 'utf-8');
935
- console.log('✓ GAL CLI rules installed');
936
- }
937
-
938
- return true;
939
- } catch (error) {
940
- // Silent fail - rules are optional enhancement
941
- return false;
942
- }
943
- }
944
-
945
- // =============================================================================
946
- // Installation Functions - Telemetry Queue
947
- // =============================================================================
948
-
949
- /**
950
- * Queue a telemetry event for the next CLI run (GAL-114)
951
- *
952
- * This postinstall script is CommonJS, but the telemetry module is ESM,
953
- * so we can't import it directly. Instead, we write events to a pending
954
- * file (~/.gal/telemetry-pending-events.json) that the CLI picks up and
955
- * flushes on its next execution.
956
- *
957
- * Events are structured as:
958
- * - id: Unique event identifier (UUID)
959
- * - eventType: 'hook_triggered' for installation
960
- * - timestamp: ISO 8601 timestamp
961
- * - payload: Event-specific data (cliVersion, platform, nodeVersion)
962
- * - queuedAt: Unix timestamp when event was queued
963
- *
964
- * @returns {void}
965
- */
966
- function queueTelemetryEvent() {
967
- const pendingEventsPath = path.join(os.homedir(), '.gal', 'telemetry-pending-events.json');
968
- const galDir = path.join(os.homedir(), '.gal');
969
-
970
- let pending = [];
971
- try {
972
- if (fs.existsSync(pendingEventsPath)) {
973
- pending = JSON.parse(fs.readFileSync(pendingEventsPath, 'utf-8'));
974
- }
975
- } catch {}
976
-
977
- // Add postinstall hook event
978
- pending.push({
979
- id: require('crypto').randomUUID(),
980
- eventType: 'hook_triggered',
981
- timestamp: new Date().toISOString(),
982
- payload: {
983
- notificationType: 'postinstall',
984
- cliVersion,
985
- platform: process.platform,
986
- nodeVersion: process.version,
987
- },
988
- queuedAt: Date.now(),
989
- });
990
-
991
- try {
992
- if (!fs.existsSync(galDir)) {
993
- fs.mkdirSync(galDir, { recursive: true });
994
- }
995
- fs.writeFileSync(pendingEventsPath, JSON.stringify(pending), 'utf-8');
996
- } catch {
997
- // Ignore errors - telemetry is optional
998
- }
999
- }
1000
-
1001
- // =============================================================================
1002
- // Installation Functions - Status Line Script
1003
- // =============================================================================
1004
-
1005
- /**
1006
- * Install the status line script to ~/.claude/status_lines/gal-sync-status.py
1007
- *
1008
- * The status line script runs continuously in Claude's status bar, showing
1009
- * sync warnings when the project is not synced with the org's approved config.
1010
- * Silent when synced (avoids status bar spam).
1011
- *
1012
- * Key behaviors:
1013
- * - Idempotent: Checks STATUS_LINE_VERSION marker before writing
1014
- * - Respectful: Won't overwrite user's existing custom statusLine
1015
- * - Registration: Adds to ~/.claude/settings.json statusLine field
1016
- * - Self-cleaning: Script removes itself if GAL CLI is uninstalled
1017
- *
1018
- * @returns {boolean} True if status line was installed or updated, false on error
1019
- */
1020
- function installStatusLine() {
1021
- const claudeDir = path.join(os.homedir(), '.claude');
1022
- const statusLinesDir = path.join(claudeDir, 'status_lines');
1023
- const scriptPath = path.join(statusLinesDir, 'gal-sync-status.py');
1024
- const settingsPath = path.join(claudeDir, 'settings.json');
1025
-
1026
- try {
1027
- // Check existing settings for custom statusLine
1028
- let settings = {};
1029
- if (fs.existsSync(settingsPath)) {
1030
- try {
1031
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1032
- } catch {
1033
- settings = {};
1034
- }
1035
- }
1036
-
1037
- // Don't overwrite user's custom statusLine (respect user's existing config)
1038
- if (settings.statusLine?.command && !settings.statusLine.command.includes('gal-sync-status')) {
1039
- console.log('ℹ Custom statusLine detected, skipping GAL status line');
1040
- return false;
1041
- }
1042
-
1043
- // Create directories if needed
1044
- if (!fs.existsSync(statusLinesDir)) {
1045
- fs.mkdirSync(statusLinesDir, { recursive: true });
1046
- }
1047
-
1048
- // Check if script already exists with current version
1049
- let needsUpdate = true;
1050
- if (fs.existsSync(scriptPath)) {
1051
- const existingContent = fs.readFileSync(scriptPath, 'utf-8');
1052
- const versionMatch = existingContent.match(/GAL_STATUS_LINE_VERSION = "([^"]+)"/);
1053
- if (versionMatch && versionMatch[1] === STATUS_LINE_VERSION) {
1054
- // Also check if it's registered in settings
1055
- if (settings.statusLine?.command?.includes('gal-sync-status')) {
1056
- needsUpdate = false;
1057
- }
1058
- }
1059
- }
1060
-
1061
- // Write the script file if needed
1062
- if (needsUpdate) {
1063
- fs.writeFileSync(scriptPath, STATUS_LINE_CONTENT, 'utf-8');
1064
- fs.chmodSync(scriptPath, '755');
1065
-
1066
- // Register in settings.json
1067
- settings.statusLine = {
1068
- type: 'command',
1069
- command: scriptPath
1070
- };
1071
-
1072
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
1073
- console.log('✓ GAL status line installed');
1074
- }
1075
-
1076
- return true;
1077
- } catch (error) {
1078
- // Silent fail - status line is optional enhancement
1079
- return false;
1080
- }
1081
- }
1082
-
1083
- function detectPackageManager() {
1084
- const userAgent = process.env.npm_config_user_agent || '';
1085
-
1086
- if (userAgent.startsWith('pnpm/')) {
1087
- return 'pnpm';
1088
- }
1089
-
1090
- if (userAgent.startsWith('npm/')) {
1091
- return 'npm';
1092
- }
1093
-
1094
- return 'unknown';
1095
- }
1096
-
1097
- function recordInstallMetadata() {
1098
- try {
1099
- const galDir = path.join(os.homedir(), '.gal');
1100
- if (!fs.existsSync(galDir)) {
1101
- fs.mkdirSync(galDir, { recursive: true });
1102
- }
1103
-
1104
- const packageManager = detectPackageManager();
1105
- const method = packageManager === 'pnpm' ? 'pnpm' : packageManager === 'npm' ? 'npm' : 'unknown';
1106
- const binaryPath = path.join(__dirname, '..', 'dist', 'index.cjs');
1107
- const metadataPath = path.join(galDir, 'install-metadata.json');
1108
- const metadata = {
1109
- binaryPath,
1110
- installedAt: new Date().toISOString(),
1111
- method,
1112
- packageManager,
1113
- platform: process.platform,
1114
- version: cliVersion,
1115
- };
1116
-
1117
- fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
1118
- } catch {
1119
- // Silent fail - metadata is best-effort only
1120
- }
1121
- }
1122
-
1123
- // =============================================================================
1124
- // Main
1125
- // =============================================================================
1126
-
1127
- function main() {
1128
- recordInstallMetadata();
1129
- const hookInstalled = installHook();
1130
- const rulesInstalled = installRules();
1131
- const statusLineInstalled = installStatusLine();
1132
-
1133
- // Queue telemetry event (GAL-114)
1134
- queueTelemetryEvent();
1135
-
1136
- if (hookInstalled || rulesInstalled || statusLineInstalled) {
1137
- console.log('');
1138
- console.log('Restart Claude Code/Cursor for changes to take effect.');
1139
- console.log('');
1140
- console.log('Next steps:');
1141
- console.log(' 1. gal auth login - Authenticate with GitHub');
1142
- console.log(' 2. gal sync --pull - Download org-approved config');
1143
- console.log('');
1144
- }
1145
- }
1146
-
1147
- main();