@scheduler-systems/gal-run 0.0.197
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/LICENSE +14 -0
- package/README.md +133 -0
- package/dist/index.cjs +2 -0
- package/package.json +107 -0
- package/scripts/postinstall.cjs +891 -0
- package/scripts/preuninstall.cjs +237 -0
|
@@ -0,0 +1,891 @@
|
|
|
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 CLI_VERSION = 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: CLI_VERSION,
|
|
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
|
+
// =============================================================================
|
|
869
|
+
// Main
|
|
870
|
+
// =============================================================================
|
|
871
|
+
|
|
872
|
+
function main() {
|
|
873
|
+
const hookInstalled = installHook();
|
|
874
|
+
const rulesInstalled = installRules();
|
|
875
|
+
const statusLineInstalled = installStatusLine();
|
|
876
|
+
|
|
877
|
+
// Queue telemetry event (GAL-114)
|
|
878
|
+
queueTelemetryEvent();
|
|
879
|
+
|
|
880
|
+
if (hookInstalled || rulesInstalled || statusLineInstalled) {
|
|
881
|
+
console.log('');
|
|
882
|
+
console.log('Restart Claude Code/Cursor for changes to take effect.');
|
|
883
|
+
console.log('');
|
|
884
|
+
console.log('Next steps:');
|
|
885
|
+
console.log(' 1. gal auth login - Authenticate with GitHub');
|
|
886
|
+
console.log(' 2. gal sync --pull - Download org-approved config');
|
|
887
|
+
console.log('');
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
main();
|