aircana 3.2.1 → 4.0.0.rc2

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