aircana 3.2.0 → 4.0.0.rc1

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +174 -159
  3. data/CHANGELOG.md +31 -0
  4. data/CLAUDE.md +50 -39
  5. data/README.md +70 -76
  6. data/lib/aircana/cli/app.rb +16 -21
  7. data/lib/aircana/cli/commands/generate.rb +0 -12
  8. data/lib/aircana/cli/commands/kb.rb +579 -0
  9. data/lib/aircana/cli/help_formatter.rb +5 -4
  10. data/lib/aircana/configuration.rb +13 -28
  11. data/lib/aircana/contexts/confluence.rb +55 -24
  12. data/lib/aircana/contexts/confluence_content.rb +4 -4
  13. data/lib/aircana/contexts/local.rb +8 -9
  14. data/lib/aircana/contexts/manifest.rb +46 -34
  15. data/lib/aircana/contexts/web.rb +47 -17
  16. data/lib/aircana/generators/skills_generator.rb +126 -0
  17. data/lib/aircana/templates/skills/base_skill.erb +12 -0
  18. data/lib/aircana/version.rb +1 -1
  19. metadata +4 -18
  20. data/lib/aircana/cli/commands/agents.rb +0 -733
  21. data/lib/aircana/generators/agents_generator.rb +0 -79
  22. data/lib/aircana/templates/agents/base_agent.erb +0 -31
  23. data/lib/aircana/templates/agents/defaults/apply_feedback.erb +0 -91
  24. data/lib/aircana/templates/agents/defaults/executor.erb +0 -84
  25. data/lib/aircana/templates/agents/defaults/jira.erb +0 -45
  26. data/lib/aircana/templates/agents/defaults/planner.erb +0 -63
  27. data/lib/aircana/templates/agents/defaults/reviewer.erb +0 -94
  28. data/lib/aircana/templates/agents/defaults/sub-agent-coordinator.erb +0 -90
  29. data/lib/aircana/templates/hooks/refresh_agents.erb +0 -66
  30. data/lib/aircana/templates/hooks/sync_local_knowledge.erb +0 -86
  31. data/spec_target_1760656566_428/agents/test-agent/manifest.json +0 -16
  32. data/spec_target_1760656588_38/agents/test-agent/manifest.json +0 -16
  33. data/spec_target_1760656647_612/agents/test-agent/manifest.json +0 -16
  34. data/spec_target_1760656660_113/agents/test-agent/manifest.json +0 -16
  35. data/spec_target_1760656689_268/agents/test-agent/manifest.json +0 -16
  36. data/spec_target_1760656710_387/agents/test-agent/manifest.json +0 -16
@@ -6,7 +6,6 @@ require_relative "../../generators/execute_command_generator"
6
6
  require_relative "../../generators/review_command_generator"
7
7
  require_relative "../../generators/apply_feedback_command_generator"
8
8
  require_relative "../../generators/ask_expert_command_generator"
9
- require_relative "../../generators/agents_generator"
10
9
  require_relative "../../generators/hooks_generator"
11
10
 
12
11
  module Aircana
@@ -27,7 +26,6 @@ module Aircana
27
26
  def run
28
27
  clean_output_directories
29
28
  generators.each(&:generate)
30
- generate_default_agents
31
29
  generate_default_hooks
32
30
  Aircana.human_logger.success("Re-generated #{Aircana.configuration.output_dir} files.")
33
31
  end
@@ -38,16 +36,6 @@ module Aircana
38
36
  # Remove stale command files to prevent duplicates during init
39
37
  commands_dir = File.join(Aircana.configuration.output_dir, "commands")
40
38
  FileUtils.rm_rf(Dir.glob("#{commands_dir}/*")) if Dir.exist?(commands_dir)
41
-
42
- # Remove stale agent files for consistency
43
- agents_dir = File.join(Aircana.configuration.output_dir, "agents")
44
- FileUtils.rm_rf(Dir.glob("#{agents_dir}/*")) if Dir.exist?(agents_dir)
45
- end
46
-
47
- def generate_default_agents
48
- Aircana::Generators::AgentsGenerator.available_default_agents.each do |agent_name|
49
- Aircana::Generators::AgentsGenerator.create_default_agent(agent_name)
50
- end
51
39
  end
52
40
 
53
41
  def generate_default_hooks
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty-prompt"
5
+ require_relative "../../generators/skills_generator"
6
+ require_relative "../../contexts/manifest"
7
+ require_relative "../../contexts/web"
8
+
9
+ module Aircana
10
+ module CLI
11
+ module KB # rubocop:disable Metrics/ModuleLength
12
+ class << self # rubocop:disable Metrics/ClassLength
13
+ def refresh(kb_name)
14
+ normalized_kb_name = normalize_string(kb_name)
15
+
16
+ # Check if this is a local knowledge base
17
+ kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_kb_name)
18
+ if kb_type == "local"
19
+ Aircana.human_logger.info "⊘ Skipping #{normalized_kb_name} (local knowledge base - no refresh needed)"
20
+ return
21
+ end
22
+
23
+ perform_manifest_aware_refresh(normalized_kb_name)
24
+ regenerate_skill_md(normalized_kb_name)
25
+ rescue Aircana::Error => e
26
+ handle_refresh_error(normalized_kb_name, e)
27
+ end
28
+
29
+ def create # rubocop:disable Metrics/MethodLength
30
+ prompt = TTY::Prompt.new
31
+
32
+ kb_name = prompt.ask("Knowledge base name:")
33
+ short_description = prompt.ask("Briefly describe what this KB contains:")
34
+
35
+ # Prompt for knowledge base type
36
+ kb_type = prompt.select("Knowledge base type:", [
37
+ {
38
+ name: "Local - Version controlled, no refresh needed",
39
+ value: "local"
40
+ },
41
+ {
42
+ name: "Remote - Fetched from Confluence/web, " \
43
+ "auto-refreshed via SessionStart hook",
44
+ value: "remote"
45
+ }
46
+ ])
47
+
48
+ normalized_kb_name = normalize_string(kb_name)
49
+
50
+ # Prompt for knowledge fetching
51
+ prompt_for_knowledge_fetch(prompt, normalized_kb_name, kb_type, short_description)
52
+
53
+ # Prompt for web URL fetching
54
+ prompt_for_url_fetch(prompt, normalized_kb_name, kb_type)
55
+
56
+ # Generate SKILL.md
57
+ regenerate_skill_md(normalized_kb_name, short_description)
58
+
59
+ # If remote kb_type, ensure SessionStart hook is installed
60
+ ensure_remote_knowledge_refresh_hook if kb_type == "remote"
61
+
62
+ # Ensure gitignore is configured
63
+ ensure_gitignore_entry(kb_type)
64
+
65
+ Aircana.human_logger.success "Knowledge base '#{kb_name}' setup complete!"
66
+ end
67
+
68
+ def list
69
+ kb_dir = Aircana.configuration.kb_knowledge_dir
70
+ return print_no_kbs_message unless Dir.exist?(kb_dir)
71
+
72
+ kb_folders = find_kb_folders(kb_dir)
73
+ return print_no_kbs_message if kb_folders.empty?
74
+
75
+ print_kbs_list(kb_folders)
76
+ end
77
+
78
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
79
+ def add_url(kb_name, url)
80
+ normalized_kb_name = normalize_string(kb_name)
81
+
82
+ unless kb_exists?(normalized_kb_name)
83
+ Aircana.human_logger.error "KB '#{kb_name}' not found. Use 'aircana kb list' to see available KBs."
84
+ exit 1
85
+ end
86
+
87
+ # Get kb_type from manifest
88
+ kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_kb_name)
89
+
90
+ web = Aircana::Contexts::Web.new
91
+ result = web.fetch_url_for(kb_name: normalized_kb_name, url: url, kb_type: kb_type)
92
+
93
+ if result
94
+ # Update manifest with the new URL
95
+ existing_sources = Aircana::Contexts::Manifest.sources_from_manifest(normalized_kb_name)
96
+ web_sources = existing_sources.select { |s| s["type"] == "web" }
97
+ other_sources = existing_sources.reject { |s| s["type"] == "web" }
98
+
99
+ if web_sources.any?
100
+ # Add to existing web source
101
+ web_sources.first["urls"] << result
102
+ else
103
+ # Create new web source
104
+ web_sources = [{ "type" => "web", "urls" => [result] }]
105
+ end
106
+
107
+ all_sources = other_sources + web_sources
108
+ Aircana::Contexts::Manifest.update_manifest(normalized_kb_name, all_sources)
109
+
110
+ # Regenerate SKILL.md
111
+ regenerate_skill_md(normalized_kb_name)
112
+
113
+ Aircana.human_logger.success "Successfully added URL to KB '#{kb_name}'"
114
+ else
115
+ Aircana.human_logger.error "Failed to fetch URL: #{url}"
116
+ exit 1
117
+ end
118
+ rescue Aircana::Error => e
119
+ Aircana.human_logger.error "Failed to add URL: #{e.message}"
120
+ exit 1
121
+ end
122
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
123
+
124
+ def refresh_all # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
125
+ kb_names = all_kbs
126
+
127
+ if kb_names.empty?
128
+ Aircana.human_logger.info "No knowledge bases found to refresh."
129
+ return
130
+ end
131
+
132
+ Aircana.human_logger.info "Starting refresh for #{kb_names.size} KB(s)..."
133
+
134
+ results = {
135
+ total: kb_names.size,
136
+ successful: 0,
137
+ failed: 0,
138
+ skipped: 0,
139
+ total_pages: 0,
140
+ failed_kbs: [],
141
+ skipped_kbs: []
142
+ }
143
+
144
+ kb_names.each do |kb_name|
145
+ # Check if this is a local knowledge base
146
+ kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(kb_name)
147
+ if kb_type == "local"
148
+ Aircana.human_logger.info "⊘ Skipping #{kb_name} (local knowledge base)"
149
+ results[:skipped] += 1
150
+ results[:skipped_kbs] << kb_name
151
+ next
152
+ end
153
+
154
+ result = refresh_single_kb(kb_name)
155
+ if result[:success]
156
+ results[:successful] += 1
157
+ results[:total_pages] += result[:pages_count]
158
+ else
159
+ results[:failed] += 1
160
+ results[:failed_kbs] << { name: kb_name, error: result[:error] }
161
+ end
162
+ end
163
+
164
+ print_refresh_all_summary(results)
165
+ end
166
+
167
+ private
168
+
169
+ def perform_refresh(normalized_kb_name, kb_type, label: nil)
170
+ confluence = Aircana::Contexts::Confluence.new
171
+ result = confluence.fetch_pages_for(kb_name: normalized_kb_name, kb_type: kb_type, label: label)
172
+
173
+ log_refresh_result(normalized_kb_name, result[:pages_count])
174
+ result
175
+ end
176
+
177
+ def log_refresh_result(normalized_kb_name, pages_count)
178
+ if pages_count.positive?
179
+ Aircana.human_logger.success "Successfully refreshed #{pages_count} pages for KB '#{normalized_kb_name}'"
180
+ else
181
+ log_no_pages_found(normalized_kb_name)
182
+ end
183
+ end
184
+
185
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
186
+ def perform_manifest_aware_refresh(normalized_kb_name)
187
+ total_pages = 0
188
+ all_sources = []
189
+
190
+ # Try manifest-based refresh first
191
+ if Aircana::Contexts::Manifest.manifest_exists?(normalized_kb_name)
192
+ Aircana.human_logger.info "Refreshing from knowledge manifest..."
193
+
194
+ # Refresh Confluence sources
195
+ confluence = Aircana::Contexts::Confluence.new
196
+ confluence_result = confluence.refresh_from_manifest(kb_name: normalized_kb_name)
197
+ total_pages += confluence_result[:pages_count]
198
+ all_sources.concat(confluence_result[:sources])
199
+
200
+ # Refresh web sources
201
+ web = Aircana::Contexts::Web.new
202
+ web_result = web.refresh_web_sources(kb_name: normalized_kb_name)
203
+ total_pages += web_result[:pages_count]
204
+ all_sources.concat(web_result[:sources])
205
+ else
206
+ Aircana.human_logger.info "No manifest found, falling back to label-based search..."
207
+ kb_type = "remote" # Default to remote if no manifest
208
+ confluence = Aircana::Contexts::Confluence.new
209
+ confluence_result = confluence.fetch_pages_for(kb_name: normalized_kb_name, kb_type: kb_type)
210
+ total_pages += confluence_result[:pages_count]
211
+ all_sources.concat(confluence_result[:sources])
212
+ end
213
+
214
+ # Update manifest with all sources combined
215
+ Aircana::Contexts::Manifest.update_manifest(normalized_kb_name, all_sources) if all_sources.any?
216
+
217
+ log_refresh_result(normalized_kb_name, total_pages)
218
+ { pages_count: total_pages, sources: all_sources }
219
+ end
220
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
221
+
222
+ # rubocop:disable Metrics/MethodLength
223
+ def regenerate_skill_md(kb_name, short_description = nil)
224
+ return unless Aircana::Contexts::Manifest.manifest_exists?(kb_name)
225
+
226
+ generator = if short_description
227
+ Generators::SkillsGenerator.new(
228
+ kb_name: kb_name,
229
+ short_description: short_description
230
+ )
231
+ else
232
+ Generators::SkillsGenerator.from_manifest(kb_name)
233
+ end
234
+
235
+ generator.generate
236
+ Aircana.human_logger.success "Generated SKILL.md for '#{kb_name}'"
237
+ rescue StandardError => e
238
+ Aircana.human_logger.warn "Failed to generate SKILL.md: #{e.message}"
239
+ end
240
+ # rubocop:enable Metrics/MethodLength
241
+
242
+ def ensure_gitignore_entry(kb_type)
243
+ gitignore_path = gitignore_file_path
244
+
245
+ if kb_type == "remote"
246
+ # For remote KBs, ensure knowledge files are ignored
247
+ ensure_remote_knowledge_ignored(gitignore_path)
248
+ else
249
+ # For local KBs, ensure skills directory is NOT ignored
250
+ ensure_local_knowledge_not_ignored(gitignore_path)
251
+ end
252
+ rescue StandardError => e
253
+ Aircana.human_logger.warn "Could not update .gitignore: #{e.message}"
254
+ end
255
+
256
+ def ensure_remote_knowledge_ignored(gitignore_path)
257
+ pattern = remote_knowledge_pattern
258
+ return if gitignore_has_pattern?(gitignore_path, pattern)
259
+
260
+ append_to_gitignore(gitignore_path, pattern)
261
+ Aircana.human_logger.success "Added remote knowledge files to .gitignore"
262
+ end
263
+
264
+ def ensure_local_knowledge_not_ignored(gitignore_path)
265
+ negation_pattern = local_knowledge_negation_pattern
266
+ return if gitignore_has_pattern?(gitignore_path, negation_pattern)
267
+
268
+ # Add comment and negation pattern
269
+ comment = "# Local KB knowledge IS version controlled (don't ignore)"
270
+ content_to_append = "\n#{comment}\n#{negation_pattern}\n"
271
+
272
+ existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
273
+ needs_newline = !existing_content.empty? && !existing_content.end_with?("\n")
274
+ content_to_append = "\n#{content_to_append}" if needs_newline
275
+
276
+ File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
277
+ Aircana.human_logger.success "Added local knowledge negation to .gitignore"
278
+ end
279
+
280
+ def gitignore_file_path
281
+ File.join(Aircana.configuration.project_dir, ".gitignore")
282
+ end
283
+
284
+ def remote_knowledge_pattern
285
+ ".claude/skills/*/*.md"
286
+ end
287
+
288
+ def local_knowledge_negation_pattern
289
+ "!.claude/skills/*/*.md"
290
+ end
291
+
292
+ def gitignore_has_pattern?(gitignore_path, pattern)
293
+ return false unless File.exist?(gitignore_path)
294
+
295
+ content = File.read(gitignore_path)
296
+ if content.lines.any? { |line| line.strip == pattern }
297
+ Aircana.human_logger.info "Pattern '#{pattern}' already in .gitignore"
298
+ true
299
+ else
300
+ false
301
+ end
302
+ end
303
+
304
+ def append_to_gitignore(gitignore_path, pattern)
305
+ existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
306
+ content_to_append = existing_content.empty? || existing_content.end_with?("\n") ? "" : "\n"
307
+ content_to_append += "#{pattern}\n"
308
+
309
+ File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
310
+ end
311
+
312
+ def log_no_pages_found(normalized_kb_name)
313
+ Aircana.human_logger.info "No pages found for KB '#{normalized_kb_name}'. " \
314
+ "Make sure pages are labeled with '#{normalized_kb_name}' in Confluence."
315
+ end
316
+
317
+ def handle_refresh_error(normalized_kb_name, error)
318
+ Aircana.human_logger.error "Failed to refresh KB '#{normalized_kb_name}': #{error.message}"
319
+ exit 1
320
+ end
321
+
322
+ def normalize_string(string)
323
+ string.strip.downcase.gsub(" ", "-")
324
+ end
325
+
326
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
327
+ # rubocop:disable Metrics/PerceivedComplexity
328
+ def prompt_for_knowledge_fetch(prompt, normalized_kb_name, kb_type, short_description)
329
+ return unless confluence_configured?
330
+
331
+ if prompt.yes?("Would you like to fetch knowledge for this KB from Confluence now?")
332
+ Aircana.human_logger.info "Fetching knowledge from Confluence..."
333
+
334
+ # Optionally ask for custom label
335
+ use_custom_label = prompt.yes?("Use a custom Confluence label? (default: #{normalized_kb_name})")
336
+ label = if use_custom_label
337
+ prompt.ask("Enter Confluence label:")
338
+ else
339
+ normalized_kb_name
340
+ end
341
+
342
+ result = perform_refresh(normalized_kb_name, kb_type, label: label)
343
+ ensure_gitignore_entry(kb_type) if result[:pages_count]&.positive?
344
+ regenerate_skill_md(normalized_kb_name, short_description) if result[:pages_count]&.positive?
345
+ else
346
+ refresh_message = if kb_type == "local"
347
+ "fetch knowledge"
348
+ else
349
+ "run 'aircana kb refresh #{normalized_kb_name}'"
350
+ end
351
+ Aircana.human_logger.info(
352
+ "Skipping knowledge fetch. You can #{refresh_message} later."
353
+ )
354
+ end
355
+ rescue Aircana::Error => e
356
+ Aircana.human_logger.warn "Failed to fetch knowledge: #{e.message}"
357
+ refresh_message = if kb_type == "local"
358
+ "fetch knowledge"
359
+ else
360
+ "try again later with 'aircana kb refresh #{normalized_kb_name}'"
361
+ end
362
+ Aircana.human_logger.info "You can #{refresh_message}"
363
+ end
364
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
365
+ # rubocop:enable Metrics/PerceivedComplexity
366
+
367
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
368
+ # rubocop:disable Metrics/PerceivedComplexity
369
+ def prompt_for_url_fetch(prompt, normalized_kb_name, kb_type)
370
+ return unless prompt.yes?("Would you like to add web URLs for this KB's knowledge base?")
371
+
372
+ urls = []
373
+ loop do
374
+ url = prompt.ask("Enter URL (or press Enter to finish):")
375
+ break if url.nil? || url.strip.empty?
376
+
377
+ url = url.strip
378
+ if valid_url?(url)
379
+ urls << url
380
+ else
381
+ Aircana.human_logger.warn "Invalid URL format: #{url}. Please enter a valid HTTP or HTTPS URL."
382
+ end
383
+ end
384
+
385
+ return if urls.empty?
386
+
387
+ begin
388
+ Aircana.human_logger.info "Fetching #{urls.size} URL(s)..."
389
+ web = Aircana::Contexts::Web.new
390
+ result = web.fetch_urls_for(kb_name: normalized_kb_name, urls: urls, kb_type: kb_type)
391
+
392
+ if result[:pages_count].positive?
393
+ Aircana.human_logger.success "Successfully fetched #{result[:pages_count]} URL(s)"
394
+ ensure_gitignore_entry(kb_type)
395
+ regenerate_skill_md(normalized_kb_name)
396
+ else
397
+ Aircana.human_logger.warn "No URLs were successfully fetched"
398
+ end
399
+ rescue Aircana::Error => e
400
+ Aircana.human_logger.warn "Failed to fetch URLs: #{e.message}"
401
+ Aircana.human_logger.info(
402
+ "You can add URLs later with 'aircana kb add-url #{normalized_kb_name} <URL>'"
403
+ )
404
+ end
405
+ end
406
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
407
+ # rubocop:enable Metrics/PerceivedComplexity
408
+
409
+ # rubocop:disable Metrics/AbcSize
410
+ def confluence_configured?
411
+ config = Aircana.configuration
412
+
413
+ base_url_present = !config.confluence_base_url.nil? && !config.confluence_base_url.empty?
414
+ username_present = !config.confluence_username.nil? && !config.confluence_username.empty?
415
+ token_present = !config.confluence_api_token.nil? && !config.confluence_api_token.empty?
416
+
417
+ base_url_present && username_present && token_present
418
+ end
419
+ # rubocop:enable Metrics/AbcSize
420
+
421
+ def print_no_kbs_message
422
+ Aircana.human_logger.info("No knowledge bases configured yet.")
423
+ end
424
+
425
+ def find_kb_folders(kb_dir)
426
+ Dir.entries(kb_dir).select do |entry|
427
+ path = File.join(kb_dir, entry)
428
+ File.directory?(path) && !entry.start_with?(".")
429
+ end.sort
430
+ end
431
+
432
+ def print_kbs_list(kb_folders)
433
+ Aircana.human_logger.info("Configured knowledge bases:")
434
+ kb_folders.each_with_index do |kb_name, index|
435
+ kb_type = get_kb_type(kb_name)
436
+ sources_count = get_sources_count(kb_name)
437
+ Aircana.human_logger.info(" #{index + 1}. #{kb_name} (#{kb_type}, #{sources_count} sources)")
438
+ end
439
+ Aircana.human_logger.info("\nTotal: #{kb_folders.length} knowledge bases")
440
+ end
441
+
442
+ def get_kb_type(kb_name)
443
+ Aircana::Contexts::Manifest.kb_type_from_manifest(kb_name) || "unknown"
444
+ end
445
+
446
+ def get_sources_count(kb_name)
447
+ sources = Aircana::Contexts::Manifest.sources_from_manifest(kb_name)
448
+ sources.size
449
+ rescue StandardError
450
+ 0
451
+ end
452
+
453
+ def kb_exists?(kb_name)
454
+ kb_dir = File.join(Aircana.configuration.kb_knowledge_dir, kb_name)
455
+ Dir.exist?(kb_dir)
456
+ end
457
+
458
+ def valid_url?(url)
459
+ uri = URI.parse(url)
460
+ %w[http https].include?(uri.scheme) && !uri.host.nil?
461
+ rescue URI::InvalidURIError
462
+ false
463
+ end
464
+
465
+ def all_kbs
466
+ kb_dir = Aircana.configuration.kb_knowledge_dir
467
+ return [] unless Dir.exist?(kb_dir)
468
+
469
+ find_kb_folders(kb_dir)
470
+ end
471
+
472
+ # rubocop:disable Metrics/MethodLength
473
+ def refresh_single_kb(kb_name)
474
+ Aircana.human_logger.info "Refreshing KB '#{kb_name}'..."
475
+
476
+ begin
477
+ result = perform_manifest_aware_refresh(kb_name)
478
+ regenerate_skill_md(kb_name)
479
+ {
480
+ success: true,
481
+ pages_count: result[:pages_count],
482
+ sources: result[:sources]
483
+ }
484
+ rescue Aircana::Error => e
485
+ Aircana.human_logger.error "Failed to refresh KB '#{kb_name}': #{e.message}"
486
+ {
487
+ success: false,
488
+ pages_count: 0,
489
+ sources: [],
490
+ error: e.message
491
+ }
492
+ end
493
+ end
494
+ # rubocop:enable Metrics/MethodLength
495
+
496
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
497
+ def print_refresh_all_summary(results)
498
+ Aircana.human_logger.info ""
499
+ Aircana.human_logger.info "=== Refresh All Summary ==="
500
+ Aircana.human_logger.success "✓ Successful: #{results[:successful]}/#{results[:total]} KBs"
501
+ Aircana.human_logger.success "✓ Total pages refreshed: #{results[:total_pages]}"
502
+
503
+ if results[:skipped].positive?
504
+ Aircana.human_logger.info "⊘ Skipped: #{results[:skipped]} KB(s) (local knowledge base)"
505
+ end
506
+
507
+ if results[:failed].positive?
508
+ Aircana.human_logger.error "✗ Failed: #{results[:failed]} KBs"
509
+ Aircana.human_logger.info ""
510
+ Aircana.human_logger.info "Failed KBs:"
511
+ results[:failed_kbs].each do |failed_kb|
512
+ Aircana.human_logger.error " - #{failed_kb[:name]}: #{failed_kb[:error]}"
513
+ end
514
+ end
515
+
516
+ Aircana.human_logger.info ""
517
+ end
518
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
519
+
520
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
521
+ def ensure_remote_knowledge_refresh_hook
522
+ hooks_manifest = Aircana::HooksManifest.new(Aircana.configuration.plugin_root)
523
+
524
+ # Check if refresh hook already exists
525
+ current_hooks = hooks_manifest.read || {}
526
+ session_start_hooks = current_hooks["SessionStart"] || []
527
+
528
+ # Check if our refresh script already exists
529
+ refresh_hook_exists = session_start_hooks.any? do |hook_group|
530
+ hook_group["hooks"]&.any? { |h| h["command"]&.include?("refresh_remote_kbs.sh") }
531
+ end
532
+
533
+ return if refresh_hook_exists
534
+
535
+ # Generate the refresh script
536
+ generate_refresh_script
537
+
538
+ # Add hook to manifest
539
+ hook_entry = {
540
+ "type" => "command",
541
+ "command" => "${CLAUDE_PLUGIN_ROOT}/scripts/refresh_remote_kbs.sh"
542
+ }
543
+
544
+ hooks_manifest.add_hook(event: "SessionStart", hook_entry: hook_entry)
545
+ Aircana.human_logger.success "Added SessionStart hook to refresh remote knowledge bases"
546
+ end
547
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
548
+
549
+ # rubocop:disable Metrics/MethodLength
550
+ def generate_refresh_script
551
+ script_path = File.join(Aircana.configuration.scripts_dir, "refresh_remote_kbs.sh")
552
+ return if File.exist?(script_path)
553
+
554
+ script_content = <<~BASH
555
+ #!/bin/bash
556
+ # Auto-generated by Aircana
557
+ # Refreshes all remote knowledge bases from Confluence/web sources
558
+
559
+ cd "${CLAUDE_PLUGIN_ROOT}" || exit 1
560
+
561
+ # Only refresh if aircana is available
562
+ if ! command -v aircana &> /dev/null; then
563
+ echo "Aircana not found, skipping KB refresh"
564
+ exit 0
565
+ fi
566
+
567
+ # Refresh all remote KBs silently
568
+ aircana kb refresh-all 2>&1 | grep -E "(Successful|Failed|Error)" || true
569
+ BASH
570
+
571
+ FileUtils.mkdir_p(Aircana.configuration.scripts_dir)
572
+ File.write(script_path, script_content)
573
+ File.chmod(0o755, script_path)
574
+ end
575
+ # rubocop:enable Metrics/MethodLength
576
+ end
577
+ end
578
+ end
579
+ end
@@ -23,8 +23,7 @@ module Aircana
23
23
 
24
24
  def command_groups
25
25
  {
26
- "File Management" => %w[files],
27
- "Agent Management" => %w[agents],
26
+ "Knowledge Base Management" => %w[kb],
28
27
  "Hook Management" => %w[hooks],
29
28
  "System" => %w[generate init doctor dump-context]
30
29
  }
@@ -52,7 +51,7 @@ module Aircana
52
51
  end
53
52
 
54
53
  def subcommand?(cmd_name)
55
- %w[files agents hooks].include?(cmd_name)
54
+ %w[kb hooks].include?(cmd_name)
56
55
  end
57
56
 
58
57
  def print_subcommand_group(subcommand_name, cmd)
@@ -63,7 +62,9 @@ module Aircana
63
62
  end
64
63
 
65
64
  def get_subcommand_class(subcommand_name)
66
- class_name = "#{subcommand_name.capitalize}Subcommand"
65
+ # Handle special cases like "kb" -> "KB"
66
+ prefix = subcommand_name == "kb" ? "KB" : subcommand_name.capitalize
67
+ class_name = "#{prefix}Subcommand"
67
68
  return self.class.const_get(class_name) if self.class.const_defined?(class_name)
68
69
 
69
70
  nil