@jaguilar87/gaia-ops 3.9.0 → 3.9.2

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,117 +1,130 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * @jaguilar87/gaia-ops - Auto-update script
4
+ * @jaguilar87/gaia-ops - Update script
5
5
  *
6
6
  * Runs automatically on npm install/update (postinstall hook).
7
+ * Also available as: npx gaia-update
7
8
  *
8
9
  * Behavior:
9
10
  * - First-time install (.claude/ doesn't exist): skip silently (gaia-init handles it)
10
11
  * - Update (.claude/ exists):
11
- * - CLAUDE.md: overwrite safely (template is static, no project data to lose)
12
- * - settings.json: MERGE new rules from template, preserve user additions
13
- * - Symlinks: recreate if missing
12
+ * 1. Show version transition (previous current)
13
+ * 2. CLAUDE.md: overwrite safely (template is static)
14
+ * 3. settings.json: MERGE new rules, preserve user additions
15
+ * 4. Symlinks: recreate if missing, fix broken ones
16
+ * 5. Verify: hooks, python, project-context, config files
17
+ * 6. Report: summary with any issues found
14
18
  *
15
- * Usage: Automatic (npm postinstall hook)
19
+ * Usage:
20
+ * npm update @jaguilar87/gaia-ops # Automatic via postinstall
21
+ * npx gaia-update # Manual trigger
22
+ * npx gaia-update --verbose # Show all checks
16
23
  */
17
24
 
18
25
  import { fileURLToPath } from 'url';
19
- import { dirname, join } from 'path';
26
+ import { dirname, join, relative } from 'path';
20
27
  import fs from 'fs/promises';
21
28
  import { existsSync } from 'fs';
29
+ import { exec } from 'child_process';
30
+ import { promisify } from 'util';
22
31
  import chalk from 'chalk';
23
32
  import ora from 'ora';
24
33
 
34
+ const execAsync = promisify(exec);
25
35
  const __filename = fileURLToPath(import.meta.url);
26
36
  const __dirname = dirname(__filename);
27
37
  const CWD = process.env.INIT_CWD || process.cwd();
38
+ const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
39
+
40
+ // ============================================================================
41
+ // Version Detection
42
+ // ============================================================================
43
+
44
+ async function detectVersions() {
45
+ const current = await readPackageVersion(join(__dirname, '..', 'package.json'));
46
+
47
+ // Try to find previous version from the installed package.json backup or lock
48
+ let previous = null;
49
+ try {
50
+ const lockPath = join(CWD, 'package-lock.json');
51
+ if (existsSync(lockPath)) {
52
+ const lock = JSON.parse(await fs.readFile(lockPath, 'utf-8'));
53
+ const dep = lock.packages?.['node_modules/@jaguilar87/gaia-ops']
54
+ || lock.dependencies?.['@jaguilar87/gaia-ops'];
55
+ if (dep) previous = dep.version;
56
+ }
57
+ } catch { /* ignore */ }
58
+
59
+ return { previous, current };
60
+ }
61
+
62
+ async function readPackageVersion(path) {
63
+ try {
64
+ const pkg = JSON.parse(await fs.readFile(path, 'utf-8'));
65
+ return pkg.version;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // ============================================================================
72
+ // Update Steps
73
+ // ============================================================================
28
74
 
29
- /**
30
- * Update CLAUDE.md from template (safe overwrite - template is static).
31
- */
32
75
  async function updateClaudeMd() {
33
76
  const spinner = ora('Updating CLAUDE.md...').start();
34
-
35
77
  try {
36
78
  const templatePath = join(__dirname, '../templates/CLAUDE.template.md');
37
79
  const claudeDir = join(CWD, '.claude');
38
80
 
39
- if (!existsSync(templatePath)) {
40
- spinner.warn('Template not found, skipping CLAUDE.md update');
41
- return false;
42
- }
43
-
44
- if (!existsSync(claudeDir)) {
45
- spinner.info('First-time installation detected - skipping');
81
+ if (!existsSync(templatePath) || !existsSync(claudeDir)) {
82
+ spinner.info('Skipped (template or .claude/ not found)');
46
83
  return false;
47
84
  }
48
85
 
49
86
  const claudeMdPath = join(CWD, 'CLAUDE.md');
50
87
  await fs.copyFile(templatePath, claudeMdPath);
51
-
52
- spinner.succeed('CLAUDE.md updated (static template)');
88
+ spinner.succeed('CLAUDE.md updated');
53
89
  return true;
54
90
  } catch (error) {
55
- spinner.fail(`Failed to update CLAUDE.md: ${error.message}`);
91
+ spinner.fail(`CLAUDE.md: ${error.message}`);
56
92
  return false;
57
93
  }
58
94
  }
59
95
 
60
- /**
61
- * Merge settings.json: add new template rules, preserve user additions.
62
- *
63
- * Strategy:
64
- * - hooks: always replace from template (hooks are code, not config)
65
- * - permissions.allow: union (template + user custom rules)
66
- * - permissions.deny: union (template + user custom rules)
67
- * - permissions.ask: union (template + user custom rules)
68
- * - Other top-level keys: template wins (new features)
69
- */
70
96
  async function updateSettingsJson() {
71
- const spinner = ora('Updating settings.json...').start();
72
-
97
+ const spinner = ora('Merging settings.json...').start();
73
98
  try {
74
99
  const templatePath = join(__dirname, '../templates/settings.template.json');
75
100
  const settingsPath = join(CWD, '.claude', 'settings.json');
76
- const claudeDir = join(CWD, '.claude');
77
101
 
78
- if (!existsSync(templatePath)) {
79
- spinner.warn('Settings template not found, skipping');
80
- return false;
81
- }
82
-
83
- if (!existsSync(claudeDir)) {
84
- spinner.info('First-time installation detected - skipping');
102
+ if (!existsSync(templatePath) || !existsSync(join(CWD, '.claude'))) {
103
+ spinner.info('Skipped');
85
104
  return false;
86
105
  }
87
106
 
88
107
  const template = JSON.parse(await fs.readFile(templatePath, 'utf-8'));
89
108
 
90
- // If no existing settings, just write the template
91
109
  if (!existsSync(settingsPath)) {
92
110
  await fs.writeFile(settingsPath, JSON.stringify(template, null, 2), 'utf-8');
93
111
  spinner.succeed('settings.json created from template');
94
112
  return true;
95
113
  }
96
114
 
97
- // Read existing settings
98
115
  let existing;
99
116
  try {
100
117
  existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
101
118
  } catch {
102
- // Invalid JSON - replace with template
103
119
  await fs.writeFile(settingsPath, JSON.stringify(template, null, 2), 'utf-8');
104
- spinner.succeed('settings.json replaced (was invalid JSON)');
120
+ spinner.succeed('settings.json replaced (was invalid)');
105
121
  return true;
106
122
  }
107
123
 
108
- // Merge strategy
124
+ // Merge: hooks from template, permissions union
109
125
  const merged = { ...template };
110
-
111
- // Hooks: always from template (these are code references, not user config)
112
126
  merged.hooks = template.hooks;
113
127
 
114
- // Permissions: union of template + user custom rules
115
128
  if (existing.permissions || template.permissions) {
116
129
  merged.permissions = mergePermissions(
117
130
  template.permissions || {},
@@ -120,20 +133,16 @@ async function updateSettingsJson() {
120
133
  }
121
134
 
122
135
  await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2), 'utf-8');
123
- spinner.succeed('settings.json merged (new rules added, custom preserved)');
136
+ spinner.succeed('settings.json merged (custom rules preserved)');
124
137
  return true;
125
138
  } catch (error) {
126
- spinner.fail(`Failed to update settings.json: ${error.message}`);
139
+ spinner.fail(`settings.json: ${error.message}`);
127
140
  return false;
128
141
  }
129
142
  }
130
143
 
131
- /**
132
- * Merge permission arrays: union of template + user, template first.
133
- */
134
144
  function mergePermissions(template, existing) {
135
145
  const result = {};
136
-
137
146
  const keys = new Set([...Object.keys(template), ...Object.keys(existing)]);
138
147
 
139
148
  for (const key of keys) {
@@ -141,7 +150,6 @@ function mergePermissions(template, existing) {
141
150
  const eVal = existing[key];
142
151
 
143
152
  if (Array.isArray(tVal) && Array.isArray(eVal)) {
144
- // Union: template rules first, then user additions not in template
145
153
  const templateSet = new Set(tVal);
146
154
  const userAdditions = eVal.filter(rule => !templateSet.has(rule));
147
155
  result[key] = [...tVal, ...userAdditions];
@@ -155,44 +163,45 @@ function mergePermissions(template, existing) {
155
163
  return result;
156
164
  }
157
165
 
158
- /**
159
- * Recreate missing symlinks in .claude/ directory.
160
- */
161
- async function recreateSymlinks() {
166
+ async function updateSymlinks() {
162
167
  const spinner = ora('Checking symlinks...').start();
163
-
164
168
  try {
165
169
  const claudeDir = join(CWD, '.claude');
166
-
167
170
  if (!existsSync(claudeDir)) {
168
- spinner.info('First-time installation detected - skipping');
169
- return false;
171
+ spinner.info('Skipped (.claude/ not found)');
172
+ return { updated: false, fixed: 0, total: 0 };
170
173
  }
171
174
 
172
175
  const packagePath = join(CWD, 'node_modules', '@jaguilar87', 'gaia-ops');
173
176
  if (!existsSync(packagePath)) {
174
177
  spinner.fail('Package not found in node_modules');
175
- return false;
178
+ return { updated: false, fixed: 0, total: 0 };
176
179
  }
177
180
 
178
- const { relative } = await import('path');
179
181
  const relativePath = relative(claudeDir, packagePath);
180
-
181
- const symlinks = [
182
- 'agents', 'tools', 'hooks', 'commands',
183
- 'templates', 'config', 'speckit'
184
- ];
185
-
186
- let recreated = 0;
182
+ const symlinks = ['agents', 'tools', 'hooks', 'commands', 'templates', 'config', 'speckit'];
183
+ let fixed = 0;
187
184
 
188
185
  for (const name of symlinks) {
189
186
  const link = join(claudeDir, name);
187
+ const target = join(relativePath, name);
188
+
190
189
  if (!existsSync(link)) {
191
190
  try {
192
- await fs.symlink(join(relativePath, name), link);
193
- recreated++;
191
+ await fs.symlink(target, link);
192
+ fixed++;
193
+ } catch { /* skip */ }
194
+ } else {
195
+ // Check if symlink is broken (target doesn't resolve)
196
+ try {
197
+ await fs.realpath(link);
194
198
  } catch {
195
- // Skip
199
+ // Broken symlink — remove and recreate
200
+ try {
201
+ await fs.unlink(link);
202
+ await fs.symlink(target, link);
203
+ fixed++;
204
+ } catch { /* skip */ }
196
205
  }
197
206
  }
198
207
  }
@@ -202,52 +211,174 @@ async function recreateSymlinks() {
202
211
  if (!existsSync(changelogLink)) {
203
212
  try {
204
213
  await fs.symlink(join(relativePath, 'CHANGELOG.md'), changelogLink);
205
- recreated++;
206
- } catch {
207
- // Skip
208
- }
214
+ fixed++;
215
+ } catch { /* skip */ }
209
216
  }
210
217
 
211
- if (recreated > 0) {
212
- spinner.succeed(`Recreated ${recreated} missing symlink(s)`);
213
- return true;
218
+ const total = symlinks.length + 1;
219
+ if (fixed > 0) {
220
+ spinner.succeed(`Symlinks: fixed ${fixed}/${total}`);
221
+ } else {
222
+ spinner.succeed(`Symlinks: ${total}/${total} valid`);
214
223
  }
215
224
 
216
- spinner.succeed('All symlinks valid');
217
- return false;
225
+ return { updated: fixed > 0, fixed, total };
218
226
  } catch (error) {
219
- spinner.fail(`Failed to check symlinks: ${error.message}`);
220
- return false;
227
+ spinner.fail(`Symlinks: ${error.message}`);
228
+ return { updated: false, fixed: 0, total: 0 };
221
229
  }
222
230
  }
223
231
 
224
- async function main() {
225
- console.log(chalk.cyan('\n @jaguilar87/gaia-ops auto-update\n'));
232
+ // ============================================================================
233
+ // Post-Update Verification
234
+ // ============================================================================
235
+
236
+ async function runVerification() {
237
+ const spinner = ora('Verifying installation health...').start();
238
+ const checks = [];
239
+ const issues = [];
240
+
241
+ // 1. Hooks exist and are reachable
242
+ const hookFiles = ['pre_tool_use.py', 'post_tool_use.py', 'subagent_stop.py'];
243
+ for (const hook of hookFiles) {
244
+ const path = join(CWD, '.claude', 'hooks', hook);
245
+ if (existsSync(path)) {
246
+ checks.push({ name: hook, ok: true });
247
+ } else {
248
+ checks.push({ name: hook, ok: false });
249
+ issues.push(`Hook missing: .claude/hooks/${hook}`);
250
+ }
251
+ }
226
252
 
253
+ // 2. Python available
227
254
  try {
228
- const claudeDir = join(CWD, '.claude');
229
- const isUpdate = existsSync(claudeDir);
255
+ const { stdout } = await execAsync('python3 --version', { timeout: 5000 });
256
+ checks.push({ name: 'python3', ok: true, detail: stdout.trim() });
257
+ } catch {
258
+ checks.push({ name: 'python3', ok: false });
259
+ issues.push('Python 3 not found (required for hooks)');
260
+ }
230
261
 
231
- if (!isUpdate) {
232
- // First-time install - gaia-init will handle everything
233
- process.exit(0);
262
+ // 3. project-context.json exists and is valid
263
+ const ctxPath = join(CWD, '.claude', 'project-context', 'project-context.json');
264
+ if (existsSync(ctxPath)) {
265
+ try {
266
+ const ctx = JSON.parse(await fs.readFile(ctxPath, 'utf-8'));
267
+ const sections = Object.keys(ctx.sections || {}).length;
268
+ checks.push({ name: 'project-context.json', ok: sections >= 3, detail: `${sections} sections` });
269
+ if (sections < 3) issues.push('project-context.json has fewer than 3 sections');
270
+ } catch {
271
+ checks.push({ name: 'project-context.json', ok: false });
272
+ issues.push('project-context.json is invalid JSON');
234
273
  }
274
+ } else {
275
+ checks.push({ name: 'project-context.json', ok: false });
276
+ issues.push('project-context.json not found (run gaia-init)');
277
+ }
235
278
 
236
- const claudeUpdated = await updateClaudeMd();
237
- const settingsUpdated = await updateSettingsJson();
238
- const symlinksRecreated = await recreateSymlinks();
279
+ // 4. Config files accessible
280
+ const configFiles = ['classification-rules.json', 'git_standards.json', 'universal-rules.json'];
281
+ for (const cfg of configFiles) {
282
+ const path = join(CWD, '.claude', 'config', cfg);
283
+ if (existsSync(path)) {
284
+ checks.push({ name: cfg, ok: true });
285
+ } else {
286
+ checks.push({ name: cfg, ok: false });
287
+ if (VERBOSE) issues.push(`Config missing: .claude/config/${cfg}`);
288
+ }
289
+ }
239
290
 
240
- if (claudeUpdated || settingsUpdated || symlinksRecreated) {
241
- console.log(chalk.green('\n Auto-update completed\n'));
242
- if (settingsUpdated) {
243
- console.log(chalk.gray(' settings.json: new rules merged, your custom rules preserved'));
291
+ // 5. Agent definitions accessible
292
+ const agentFiles = ['terraform-architect.md', 'gitops-operator.md', 'cloud-troubleshooter.md', 'devops-developer.md', 'gaia.md'];
293
+ let agentsOk = 0;
294
+ for (const agent of agentFiles) {
295
+ if (existsSync(join(CWD, '.claude', 'agents', agent))) agentsOk++;
296
+ }
297
+ checks.push({ name: 'agent definitions', ok: agentsOk === agentFiles.length, detail: `${agentsOk}/${agentFiles.length}` });
298
+ if (agentsOk < agentFiles.length) issues.push(`${agentFiles.length - agentsOk} agent definition(s) missing`);
299
+
300
+ // 6. settings.json has hooks configured
301
+ const settingsPath = join(CWD, '.claude', 'settings.json');
302
+ if (existsSync(settingsPath)) {
303
+ try {
304
+ const settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
305
+ const hasHooks = settings.hooks && Object.keys(settings.hooks).length > 0;
306
+ checks.push({ name: 'hooks config', ok: hasHooks });
307
+ if (!hasHooks) issues.push('settings.json has no hooks configured');
308
+ } catch {
309
+ checks.push({ name: 'hooks config', ok: false });
310
+ issues.push('settings.json is invalid');
311
+ }
312
+ }
313
+
314
+ const passed = checks.filter(c => c.ok).length;
315
+ const total = checks.length;
316
+
317
+ if (issues.length === 0) {
318
+ spinner.succeed(`Health check: ${passed}/${total} passed`);
319
+ } else {
320
+ spinner.warn(`Health check: ${passed}/${total} passed, ${issues.length} issue(s)`);
321
+ }
322
+
323
+ return { checks, issues, passed, total };
324
+ }
325
+
326
+ // ============================================================================
327
+ // Main
328
+ // ============================================================================
329
+
330
+ async function main() {
331
+ const claudeDir = join(CWD, '.claude');
332
+ const isUpdate = existsSync(claudeDir);
333
+
334
+ if (!isUpdate) {
335
+ // First-time install — gaia-init handles everything
336
+ process.exit(0);
337
+ }
338
+
339
+ // Version info
340
+ const { previous, current } = await detectVersions();
341
+ const versionLine = previous && previous !== current
342
+ ? `${chalk.gray(previous)} → ${chalk.green(current)}`
343
+ : chalk.green(current);
344
+
345
+ console.log(chalk.cyan(`\n gaia-ops update ${versionLine}\n`));
346
+
347
+ // Step 1-3: Update files
348
+ const claudeUpdated = await updateClaudeMd();
349
+ const settingsUpdated = await updateSettingsJson();
350
+ const { updated: symlinksUpdated, fixed: symlinksFix } = await updateSymlinks();
351
+
352
+ // Step 4: Verify
353
+ const { issues, passed, total } = await runVerification();
354
+
355
+ // Summary
356
+ const changes = [claudeUpdated, settingsUpdated, symlinksUpdated].filter(Boolean).length;
357
+
358
+ console.log('');
359
+ if (changes > 0 || issues.length > 0) {
360
+ // Changes summary
361
+ if (changes > 0) {
362
+ console.log(chalk.green(` ${changes} file(s) updated`));
363
+ if (settingsUpdated) console.log(chalk.gray(' settings.json: new rules merged, custom rules preserved'));
364
+ if (symlinksFix > 0) console.log(chalk.gray(` ${symlinksFix} symlink(s) fixed`));
365
+ }
366
+
367
+ // Issues
368
+ if (issues.length > 0) {
369
+ console.log(chalk.yellow(`\n ${issues.length} issue(s) found:`));
370
+ for (const issue of issues) {
371
+ console.log(chalk.yellow(` - ${issue}`));
244
372
  }
245
- console.log(chalk.gray(' Run `npx gaia-doctor` to verify installation health\n'));
246
373
  }
247
- } catch (error) {
248
- console.error(chalk.red(`\n Auto-update failed: ${error.message}\n`));
249
- process.exit(0); // Don't fail npm install
374
+ } else {
375
+ console.log(chalk.green(' Everything up to date'));
250
376
  }
377
+
378
+ console.log(chalk.gray(`\n Health: ${passed}/${total} checks passed\n`));
251
379
  }
252
380
 
253
- main();
381
+ main().catch(error => {
382
+ console.error(chalk.red(`\n Update failed: ${error.message}\n`));
383
+ process.exit(0); // Never fail npm install
384
+ });
@@ -284,7 +284,14 @@ def _inject_project_context(parameters: dict) -> dict:
284
284
 
285
285
  {json.dumps(context_payload, indent=2)}
286
286
 
287
- {skills_content}{pending_warning}---
287
+ {skills_content}{pending_warning}# Discovery Protocol
288
+ When you find resources (namespaces, services, endpoints, configurations) that are NOT listed in the Project Context above, explicitly report the difference using phrases like:
289
+ - "Found namespace 'X' exists but not in project context"
290
+ - "Discovered new service 'Y' running in namespace 'Z'"
291
+ - "Drift detected: actual value is 'A' but context says 'B'"
292
+ This helps keep the project context up to date.
293
+
294
+ ---
288
295
 
289
296
  # User Task
290
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaguilar87/gaia-ops",
3
- "version": "3.9.0",
3
+ "version": "3.9.2",
4
4
  "description": "Multi-agent orchestration system for Claude Code - DevOps automation toolkit",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  "gaia-cleanup": "bin/gaia-cleanup.js",
11
11
  "gaia-uninstall": "bin/gaia-uninstall.js",
12
12
  "gaia-metrics": "bin/gaia-metrics.js",
13
- "gaia-review": "bin/gaia-review.js"
13
+ "gaia-review": "bin/gaia-review.js",
14
+ "gaia-update": "bin/gaia-update.js"
14
15
  },
15
16
  "keywords": [
16
17
  "claude-code",