aircana 3.2.1 → 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 +175 -160
  3. data/CHANGELOG.md +24 -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
@@ -1,733 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "tty-prompt"
5
- require_relative "../../generators/agents_generator"
6
- require_relative "../../contexts/manifest"
7
- require_relative "../../contexts/web"
8
-
9
- module Aircana
10
- module CLI
11
- module Agents # rubocop:disable Metrics/ModuleLength
12
- SUPPORTED_CLAUDE_MODELS = %w[sonnet haiku inherit].freeze
13
- SUPPORTED_CLAUDE_COLORS = %w[red blue green yellow purple orange pink cyan].freeze
14
-
15
- class << self # rubocop:disable Metrics/ClassLength
16
- def refresh(agent)
17
- normalized_agent = normalize_string(agent)
18
-
19
- # Check if this is a local knowledge base agent
20
- kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_agent)
21
- if kb_type == "local"
22
- Aircana.human_logger.info "⊘ Skipping #{normalized_agent} (local knowledge base - no refresh needed)"
23
- return
24
- end
25
-
26
- perform_manifest_aware_refresh(normalized_agent)
27
- rescue Aircana::Error => e
28
- handle_refresh_error(normalized_agent, e)
29
- end
30
-
31
- def create
32
- prompt = TTY::Prompt.new
33
-
34
- agent_name = prompt.ask("Agent name:")
35
- short_description = prompt.ask("Briefly describe what your agent does:")
36
- model = prompt.select("Select a model for your agent:", SUPPORTED_CLAUDE_MODELS)
37
- color = prompt.select("Select a color for your agent:", SUPPORTED_CLAUDE_COLORS)
38
-
39
- # Prompt for knowledge base type
40
- kb_type = prompt.select("Knowledge base type:", [
41
- {
42
- name: "Remote - Fetched from Confluence/web, stored in ~/.claude/agents/, " \
43
- "requires refresh",
44
- value: "remote"
45
- },
46
- {
47
- name: "Local - Version controlled in plugin, auto-synced to ~/.claude/agents/ " \
48
- "on session start",
49
- value: "local"
50
- }
51
- ])
52
-
53
- description = description_from_claude(short_description)
54
- normalized_agent_name = normalize_string(agent_name)
55
-
56
- file = Generators::AgentsGenerator.new(
57
- agent_name: normalized_agent_name,
58
- description:,
59
- short_description:,
60
- model: normalize_string(model),
61
- color: normalize_string(color),
62
- kb_type:
63
- ).generate
64
-
65
- Aircana.human_logger.success "Agent created at #{file}"
66
-
67
- # If local kb_type, ensure SessionStart hook is installed
68
- ensure_local_knowledge_sync_hook if kb_type == "local"
69
-
70
- # Prompt for knowledge fetching
71
- prompt_for_knowledge_fetch(prompt, normalized_agent_name, kb_type)
72
-
73
- # Prompt for web URL fetching
74
- prompt_for_url_fetch(prompt, normalized_agent_name, kb_type)
75
-
76
- # Prompt for agent file review
77
- prompt_for_agent_review(prompt, file)
78
-
79
- Aircana.human_logger.success "Agent '#{agent_name}' setup complete!"
80
- end
81
-
82
- def list
83
- agent_dir = Aircana.configuration.agent_knowledge_dir
84
- return print_no_agents_message unless Dir.exist?(agent_dir)
85
-
86
- agent_folders = find_agent_folders(agent_dir)
87
- return print_no_agents_message if agent_folders.empty?
88
-
89
- print_agents_list(agent_folders)
90
- end
91
-
92
- def add_url(agent, url)
93
- normalized_agent = normalize_string(agent)
94
-
95
- unless agent_exists?(normalized_agent)
96
- Aircana.human_logger.error "Agent '#{agent}' not found. Use 'aircana agents list' to see available agents."
97
- exit 1
98
- end
99
-
100
- # Get kb_type from manifest to know where to store
101
- kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_agent)
102
-
103
- web = Aircana::Contexts::Web.new
104
- result = web.fetch_url_for(agent: normalized_agent, url: url, kb_type: kb_type)
105
-
106
- if result
107
- # Update manifest with the new URL
108
- existing_sources = Aircana::Contexts::Manifest.sources_from_manifest(normalized_agent)
109
- web_sources = existing_sources.select { |s| s["type"] == "web" }
110
- other_sources = existing_sources.reject { |s| s["type"] == "web" }
111
-
112
- if web_sources.any?
113
- # Add to existing web source
114
- web_sources.first["urls"] << result
115
- else
116
- # Create new web source
117
- web_sources = [{ "type" => "web", "urls" => [result] }]
118
- end
119
-
120
- all_sources = other_sources + web_sources
121
- Aircana::Contexts::Manifest.update_manifest(normalized_agent, all_sources)
122
-
123
- Aircana.human_logger.success "Successfully added URL to agent '#{agent}'"
124
- else
125
- Aircana.human_logger.error "Failed to fetch URL: #{url}"
126
- exit 1
127
- end
128
- rescue Aircana::Error => e
129
- Aircana.human_logger.error "Failed to add URL: #{e.message}"
130
- exit 1
131
- end
132
-
133
- def refresh_all
134
- agent_names = all_agents
135
-
136
- if agent_names.empty?
137
- Aircana.human_logger.info "No agents found to refresh."
138
- return
139
- end
140
-
141
- Aircana.human_logger.info "Starting refresh for #{agent_names.size} agent(s)..."
142
-
143
- results = {
144
- total: agent_names.size,
145
- successful: 0,
146
- failed: 0,
147
- skipped: 0,
148
- total_pages: 0,
149
- failed_agents: [],
150
- skipped_agents: []
151
- }
152
-
153
- agent_names.each do |agent_name|
154
- # Check if this is a local knowledge base agent
155
- kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(agent_name)
156
- if kb_type == "local"
157
- Aircana.human_logger.info "⊘ Skipping #{agent_name} (local knowledge base)"
158
- results[:skipped] += 1
159
- results[:skipped_agents] << agent_name
160
- next
161
- end
162
-
163
- result = refresh_single_agent(agent_name)
164
- if result[:success]
165
- results[:successful] += 1
166
- results[:total_pages] += result[:pages_count]
167
- else
168
- results[:failed] += 1
169
- results[:failed_agents] << { name: agent_name, error: result[:error] }
170
- end
171
- end
172
-
173
- print_refresh_all_summary(results)
174
- end
175
-
176
- def migrate_to_local
177
- agent_names = all_agents
178
-
179
- if agent_names.empty?
180
- Aircana.human_logger.info "No agents found to migrate."
181
- return
182
- end
183
-
184
- # Filter to only remote agents
185
- remote_agents = agent_names.select do |agent_name|
186
- Aircana::Contexts::Manifest.kb_type_from_manifest(agent_name) == "remote"
187
- end
188
-
189
- if remote_agents.empty?
190
- Aircana.human_logger.info "No remote agents found. All agents are already using local knowledge bases."
191
- return
192
- end
193
-
194
- Aircana.human_logger.warn "⚠️ This will migrate #{remote_agents.size} agent(s) to local knowledge bases."
195
- Aircana.human_logger.warn "Knowledge will be version-controlled in agents/<agent>/knowledge/"
196
- Aircana.human_logger.warn "and auto-synced to ~/.claude/agents/ on session start"
197
- Aircana.human_logger.info ""
198
-
199
- results = {
200
- total: remote_agents.size,
201
- successful: 0,
202
- failed: 0,
203
- migrated_agents: [],
204
- failed_agents: []
205
- }
206
-
207
- remote_agents.each do |agent_name|
208
- result = migrate_single_agent(agent_name)
209
- if result[:success]
210
- results[:successful] += 1
211
- results[:migrated_agents] << agent_name
212
- else
213
- results[:failed] += 1
214
- results[:failed_agents] << { name: agent_name, error: result[:error] }
215
- end
216
- end
217
-
218
- print_migration_summary(results)
219
- end
220
-
221
- private
222
-
223
- def perform_refresh(normalized_agent, kb_type = "remote")
224
- confluence = Aircana::Contexts::Confluence.new
225
- result = confluence.fetch_pages_for(agent: normalized_agent, kb_type: kb_type)
226
-
227
- log_refresh_result(normalized_agent, result[:pages_count])
228
- result
229
- end
230
-
231
- def log_refresh_result(normalized_agent, pages_count)
232
- if pages_count.positive?
233
- Aircana.human_logger.success "Successfully refreshed #{pages_count} pages for agent '#{normalized_agent}'"
234
- else
235
- log_no_pages_found(normalized_agent)
236
- end
237
- end
238
-
239
- def perform_manifest_aware_refresh(normalized_agent)
240
- total_pages = 0
241
- all_sources = []
242
-
243
- # Try manifest-based refresh first
244
- if Aircana::Contexts::Manifest.manifest_exists?(normalized_agent)
245
- Aircana.human_logger.info "Refreshing from knowledge manifest..."
246
-
247
- # Refresh Confluence sources
248
- confluence = Aircana::Contexts::Confluence.new
249
- confluence_result = confluence.refresh_from_manifest(agent: normalized_agent)
250
- total_pages += confluence_result[:pages_count]
251
- all_sources.concat(confluence_result[:sources])
252
-
253
- # Refresh web sources
254
- web = Aircana::Contexts::Web.new
255
- web_result = web.refresh_web_sources(agent: normalized_agent)
256
- total_pages += web_result[:pages_count]
257
- all_sources.concat(web_result[:sources])
258
- else
259
- Aircana.human_logger.info "No manifest found, falling back to label-based search..."
260
- confluence = Aircana::Contexts::Confluence.new
261
- confluence_result = confluence.fetch_pages_for(agent: normalized_agent)
262
- total_pages += confluence_result[:pages_count]
263
- all_sources.concat(confluence_result[:sources])
264
- end
265
-
266
- # Update manifest with all sources combined
267
- Aircana::Contexts::Manifest.update_manifest(normalized_agent, all_sources) if all_sources.any?
268
-
269
- log_refresh_result(normalized_agent, total_pages)
270
- { pages_count: total_pages, sources: all_sources }
271
- end
272
-
273
- def ensure_gitignore_entry(kb_type = "remote")
274
- gitignore_path = gitignore_file_path
275
-
276
- if kb_type == "local"
277
- # For local agents, ensure version-controlled knowledge is not ignored
278
- ensure_local_knowledge_not_ignored(gitignore_path)
279
- else
280
- # For remote agents, add ignore pattern for .claude/agents/*/knowledge/
281
- ensure_remote_knowledge_ignored(gitignore_path)
282
- end
283
- rescue StandardError => e
284
- Aircana.human_logger.warn "Could not update .gitignore: #{e.message}"
285
- end
286
-
287
- def ensure_remote_knowledge_ignored(gitignore_path)
288
- pattern = remote_knowledge_pattern
289
- return if gitignore_has_pattern?(gitignore_path, pattern)
290
-
291
- append_to_gitignore(gitignore_path, pattern)
292
- Aircana.human_logger.success "Added remote knowledge directories to .gitignore"
293
- end
294
-
295
- def ensure_local_knowledge_not_ignored(gitignore_path)
296
- negation_pattern = local_knowledge_negation_pattern
297
- return if gitignore_has_pattern?(gitignore_path, negation_pattern)
298
-
299
- # Add comment and negation pattern
300
- comment = "# Local agent knowledge IS version controlled (don't ignore)"
301
- content_to_append = "\n#{comment}\n#{negation_pattern}\n"
302
-
303
- existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
304
- needs_newline = !existing_content.empty? && !existing_content.end_with?("\n")
305
- content_to_append = "\n#{content_to_append}" if needs_newline
306
-
307
- File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
308
- Aircana.human_logger.success "Added local knowledge negation to .gitignore"
309
- end
310
-
311
- def gitignore_file_path
312
- File.join(Aircana.configuration.project_dir, ".gitignore")
313
- end
314
-
315
- def remote_knowledge_pattern
316
- ".claude/agents/*/knowledge/"
317
- end
318
-
319
- def local_knowledge_negation_pattern
320
- "!agents/*/knowledge/"
321
- end
322
-
323
- def gitignore_has_pattern?(gitignore_path, pattern)
324
- return false unless File.exist?(gitignore_path)
325
-
326
- content = File.read(gitignore_path)
327
- if content.lines.any? { |line| line.strip == pattern }
328
- Aircana.human_logger.info "Pattern '#{pattern}' already in .gitignore"
329
- true
330
- else
331
- false
332
- end
333
- end
334
-
335
- def append_to_gitignore(gitignore_path, pattern)
336
- existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
337
- content_to_append = existing_content.empty? || existing_content.end_with?("\n") ? "" : "\n"
338
- content_to_append += "#{pattern}\n"
339
-
340
- File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
341
- end
342
-
343
- def log_no_pages_found(normalized_agent)
344
- Aircana.human_logger.info "No pages found for agent '#{normalized_agent}'. " \
345
- "Make sure pages are labeled with '#{normalized_agent}' in Confluence."
346
- end
347
-
348
- def handle_refresh_error(normalized_agent, error)
349
- Aircana.human_logger.error "Failed to refresh agent '#{normalized_agent}': #{error.message}"
350
- exit 1
351
- end
352
-
353
- def normalize_string(string)
354
- string.strip.downcase.gsub(" ", "-")
355
- end
356
-
357
- def description_from_claude(description)
358
- prompt = build_agent_description_prompt(description)
359
- claude_client = Aircana::LLM::ClaudeClient.new
360
- claude_client.prompt(prompt)
361
- end
362
-
363
- def build_agent_description_prompt(description)
364
- <<~PROMPT
365
- Create a concise Claude Code agent description file (without frontmatter)
366
- for an agent that is described as: #{description}.
367
-
368
- The agent should be specialized and focused on its domain knowledge.
369
- Include instructions that the agent should primarily rely on information
370
- from its knowledge base rather than general knowledge when answering questions
371
- within its domain.
372
-
373
- Print the output to STDOUT only, without any additional commentary.
374
-
375
- The description should be 2-3 sentences. Most of the agent's context comes from
376
- its knowledge base
377
- PROMPT
378
- end
379
-
380
- def prompt_for_knowledge_fetch(prompt, normalized_agent_name, kb_type)
381
- return unless confluence_configured?
382
-
383
- if prompt.yes?("Would you like to fetch knowledge for this agent from Confluence now?")
384
- Aircana.human_logger.info "Fetching knowledge from Confluence..."
385
- result = perform_refresh(normalized_agent_name, kb_type)
386
- ensure_gitignore_entry(kb_type) if result[:pages_count]&.positive?
387
- else
388
- refresh_message = if kb_type == "local"
389
- "fetch knowledge"
390
- else
391
- "run 'aircana agents refresh #{normalized_agent_name}'"
392
- end
393
- Aircana.human_logger.info(
394
- "Skipping knowledge fetch. You can #{refresh_message} later."
395
- )
396
- end
397
- rescue Aircana::Error => e
398
- Aircana.human_logger.warn "Failed to fetch knowledge: #{e.message}"
399
- refresh_message = if kb_type == "local"
400
- "fetch knowledge"
401
- else
402
- "try again later with 'aircana agents refresh #{normalized_agent_name}'"
403
- end
404
- Aircana.human_logger.info "You can #{refresh_message}"
405
- end
406
-
407
- def prompt_for_url_fetch(prompt, normalized_agent_name, kb_type)
408
- return unless prompt.yes?("Would you like to add web URLs for this agent's knowledge base?")
409
-
410
- urls = []
411
- loop do
412
- url = prompt.ask("Enter URL (or press Enter to finish):")
413
- break if url.nil? || url.strip.empty?
414
-
415
- url = url.strip
416
- if valid_url?(url)
417
- urls << url
418
- else
419
- Aircana.human_logger.warn "Invalid URL format: #{url}. Please enter a valid HTTP or HTTPS URL."
420
- end
421
- end
422
-
423
- return if urls.empty?
424
-
425
- begin
426
- Aircana.human_logger.info "Fetching #{urls.size} URL(s)..."
427
- web = Aircana::Contexts::Web.new
428
- result = web.fetch_urls_for(agent: normalized_agent_name, urls: urls, kb_type: kb_type)
429
-
430
- if result[:pages_count].positive?
431
- Aircana.human_logger.success "Successfully fetched #{result[:pages_count]} URL(s)"
432
- ensure_gitignore_entry(kb_type)
433
- else
434
- Aircana.human_logger.warn "No URLs were successfully fetched"
435
- end
436
- rescue Aircana::Error => e
437
- Aircana.human_logger.warn "Failed to fetch URLs: #{e.message}"
438
- Aircana.human_logger.info(
439
- "You can add URLs later with 'aircana agents add-url #{normalized_agent_name} <URL>'"
440
- )
441
- end
442
- end
443
-
444
- def prompt_for_agent_review(prompt, file_path)
445
- Aircana.human_logger.info "Agent file created at: #{file_path}"
446
-
447
- return unless prompt.yes?("Would you like to review and edit the agent file?")
448
-
449
- open_file_in_editor(file_path)
450
- end
451
-
452
- def confluence_configured?
453
- config = Aircana.configuration
454
-
455
- base_url_present = !config.confluence_base_url.nil? && !config.confluence_base_url.empty?
456
- username_present = !config.confluence_username.nil? && !config.confluence_username.empty?
457
- token_present = !config.confluence_api_token.nil? && !config.confluence_api_token.empty?
458
-
459
- base_url_present && username_present && token_present
460
- end
461
-
462
- def open_file_in_editor(file_path)
463
- editor = ENV["EDITOR"] || find_available_editor
464
-
465
- if editor
466
- Aircana.human_logger.info "Opening #{file_path} in #{editor}..."
467
- system("#{editor} '#{file_path}'")
468
- else
469
- Aircana.human_logger.warn "No editor found. Please edit #{file_path} manually."
470
- end
471
- end
472
-
473
- def print_no_agents_message
474
- Aircana.human_logger.info("No agents configured yet.")
475
- end
476
-
477
- def find_agent_folders(agent_dir)
478
- Dir.entries(agent_dir).select do |entry|
479
- path = File.join(agent_dir, entry)
480
- File.directory?(path) && !entry.start_with?(".")
481
- end
482
- end
483
-
484
- def print_agents_list(agent_folders)
485
- Aircana.human_logger.info("Configured agents:")
486
- agent_folders.each_with_index do |agent_name, index|
487
- description = get_agent_description(agent_name)
488
- Aircana.human_logger.info(" #{index + 1}. #{agent_name} - #{description}")
489
- end
490
- Aircana.human_logger.info("\nTotal: #{agent_folders.length} agents")
491
- end
492
-
493
- def get_agent_description(agent_name)
494
- agent_config_path = File.join(
495
- Aircana.configuration.agent_knowledge_dir,
496
- agent_name,
497
- "agent.json"
498
- )
499
- return "Configuration incomplete" unless File.exist?(agent_config_path)
500
-
501
- config = JSON.parse(File.read(agent_config_path))
502
- config["description"] || "No description available"
503
- end
504
-
505
- def agent_exists?(agent_name)
506
- agent_dir = File.join(Aircana.configuration.agent_knowledge_dir, agent_name)
507
- Dir.exist?(agent_dir)
508
- end
509
-
510
- def valid_url?(url)
511
- uri = URI.parse(url)
512
- %w[http https].include?(uri.scheme) && !uri.host.nil?
513
- rescue URI::InvalidURIError
514
- false
515
- end
516
-
517
- def find_available_editor
518
- %w[code subl atom nano vim vi].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
519
- end
520
-
521
- def all_agents
522
- agent_dir = Aircana.configuration.agent_knowledge_dir
523
- return [] unless Dir.exist?(agent_dir)
524
-
525
- find_agent_folders(agent_dir)
526
- end
527
-
528
- def refresh_single_agent(agent_name)
529
- Aircana.human_logger.info "Refreshing agent '#{agent_name}'..."
530
-
531
- begin
532
- result = perform_manifest_aware_refresh(agent_name)
533
- {
534
- success: true,
535
- pages_count: result[:pages_count],
536
- sources: result[:sources]
537
- }
538
- rescue Aircana::Error => e
539
- Aircana.human_logger.error "Failed to refresh agent '#{agent_name}': #{e.message}"
540
- {
541
- success: false,
542
- pages_count: 0,
543
- sources: [],
544
- error: e.message
545
- }
546
- end
547
- end
548
-
549
- def print_refresh_all_summary(results)
550
- Aircana.human_logger.info ""
551
- Aircana.human_logger.info "=== Refresh All Summary ==="
552
- Aircana.human_logger.success "✓ Successful: #{results[:successful]}/#{results[:total]} agents"
553
- Aircana.human_logger.success "✓ Total pages refreshed: #{results[:total_pages]}"
554
-
555
- if results[:skipped].positive?
556
- Aircana.human_logger.info "⊘ Skipped: #{results[:skipped]} agent(s) (local knowledge base)"
557
- end
558
-
559
- if results[:failed].positive?
560
- Aircana.human_logger.error "✗ Failed: #{results[:failed]} agents"
561
- Aircana.human_logger.info ""
562
- Aircana.human_logger.info "Failed agents:"
563
- results[:failed_agents].each do |failed_agent|
564
- Aircana.human_logger.error " - #{failed_agent[:name]}: #{failed_agent[:error]}"
565
- end
566
- end
567
-
568
- Aircana.human_logger.info ""
569
- end
570
-
571
- def migrate_single_agent(agent_name)
572
- Aircana.human_logger.info "Migrating agent '#{agent_name}'..."
573
-
574
- begin
575
- # Step 1: Refresh knowledge from sources to ensure we have latest
576
- Aircana.human_logger.info " Refreshing knowledge from sources..."
577
- perform_manifest_aware_refresh(agent_name)
578
-
579
- # Step 2: Copy knowledge files from global to local directory
580
- Aircana.human_logger.info " Copying knowledge files to local directory..."
581
- copy_knowledge_files(agent_name)
582
-
583
- # Step 3: Update manifest kb_type to "local"
584
- Aircana.human_logger.info " Updating manifest..."
585
- sources = Aircana::Contexts::Manifest.sources_from_manifest(agent_name)
586
- Aircana::Contexts::Manifest.update_manifest(agent_name, sources, kb_type: "local")
587
-
588
- # Step 4: Regenerate agent file with new kb_type
589
- Aircana.human_logger.info " Regenerating agent file..."
590
- regenerate_agent_file(agent_name)
591
-
592
- # Step 5: Ensure SessionStart hook is installed (only install once for all local agents)
593
- Aircana.human_logger.info " Ensuring SessionStart hook is installed..."
594
- ensure_local_knowledge_sync_hook
595
-
596
- Aircana.human_logger.success "✓ Successfully migrated '#{agent_name}'"
597
- { success: true }
598
- rescue StandardError => e
599
- Aircana.human_logger.error "✗ Failed to migrate agent '#{agent_name}': #{e.message}"
600
- { success: false, error: e.message }
601
- end
602
- end
603
-
604
- def copy_knowledge_files(agent_name)
605
- config = Aircana.configuration
606
- source_dir = config.global_agent_knowledge_path(agent_name)
607
- dest_dir = config.local_agent_knowledge_path(agent_name)
608
-
609
- unless Dir.exist?(source_dir)
610
- Aircana.human_logger.warn " No knowledge files found at #{source_dir}"
611
- return
612
- end
613
-
614
- FileUtils.mkdir_p(dest_dir)
615
-
616
- # Copy all markdown files
617
- Dir.glob(File.join(source_dir, "*.md")).each do |file|
618
- filename = File.basename(file)
619
- dest_file = File.join(dest_dir, filename)
620
- FileUtils.cp(file, dest_file)
621
- Aircana.human_logger.info " Copied #{filename}"
622
- end
623
- end
624
-
625
- def regenerate_agent_file(agent_name)
626
- # Read existing agent file to get metadata
627
- agent_file_path = File.join(Aircana.configuration.agents_dir, "#{agent_name}.md")
628
- unless File.exist?(agent_file_path)
629
- Aircana.human_logger.warn " Agent file not found at #{agent_file_path}, skipping regeneration"
630
- return
631
- end
632
-
633
- # Parse frontmatter to get agent metadata
634
- content = File.read(agent_file_path)
635
- frontmatter_match = content.match(/^---\n(.*?)\n---\n(.*)$/m)
636
- return unless frontmatter_match
637
-
638
- frontmatter = frontmatter_match[1]
639
- description = frontmatter_match[2]
640
-
641
- # Parse YAML-like frontmatter
642
- metadata = {}
643
- frontmatter.lines.each do |line|
644
- key, value = line.strip.split(":", 2)
645
- metadata[key.strip] = value&.strip
646
- end
647
-
648
- # Generate new agent file with kb_type="local"
649
- Generators::AgentsGenerator.new(
650
- agent_name: agent_name,
651
- description: description,
652
- short_description: metadata["description"],
653
- model: metadata["model"],
654
- color: metadata["color"],
655
- kb_type: "local"
656
- ).generate
657
- end
658
-
659
- def print_migration_summary(results)
660
- Aircana.human_logger.info ""
661
- Aircana.human_logger.info "=== Migration Summary ==="
662
- Aircana.human_logger.success "✓ Successfully migrated: #{results[:successful]}/#{results[:total]} agents"
663
-
664
- if results[:successful].positive?
665
- Aircana.human_logger.info ""
666
- Aircana.human_logger.info "Migrated agents:"
667
- results[:migrated_agents].each do |agent_name|
668
- Aircana.human_logger.success " ✓ #{agent_name}"
669
- end
670
- end
671
-
672
- if results[:failed].positive?
673
- Aircana.human_logger.error "✗ Failed: #{results[:failed]} agents"
674
- Aircana.human_logger.info ""
675
- Aircana.human_logger.info "Failed agents:"
676
- results[:failed_agents].each do |failed_agent|
677
- Aircana.human_logger.error " - #{failed_agent[:name]}: #{failed_agent[:error]}"
678
- end
679
- end
680
-
681
- if results[:successful].positive?
682
- Aircana.human_logger.info ""
683
- Aircana.human_logger.warn "⚠️ Knowledge is now version-controlled in agents/<agent>/knowledge/"
684
- Aircana.human_logger.info "A SessionStart hook has been added to auto-sync knowledge to ~/.claude/agents/"
685
- Aircana.human_logger.info "Review and commit these changes to your repository."
686
- end
687
-
688
- Aircana.human_logger.info ""
689
- end
690
-
691
- def ensure_local_knowledge_sync_hook
692
- hooks_manifest = Aircana::HooksManifest.new(Aircana.configuration.plugin_root)
693
-
694
- # Check if sync hook already exists
695
- current_hooks = hooks_manifest.read || {}
696
- session_start_hooks = current_hooks["SessionStart"] || []
697
-
698
- # Check if our sync script already exists
699
- sync_hook_exists = session_start_hooks.any? do |hook_group|
700
- hook_group["hooks"]&.any? { |h| h["command"]&.include?("sync_local_knowledge.sh") }
701
- end
702
-
703
- return if sync_hook_exists
704
-
705
- # Generate the sync script
706
- generate_sync_script
707
-
708
- # Add hook to manifest
709
- hook_entry = {
710
- "type" => "command",
711
- "command" => "${CLAUDE_PLUGIN_ROOT}/scripts/sync_local_knowledge.sh"
712
- }
713
-
714
- hooks_manifest.add_hook(event: "SessionStart", hook_entry: hook_entry)
715
- Aircana.human_logger.success "Added SessionStart hook to sync local knowledge bases"
716
- end
717
-
718
- def generate_sync_script
719
- script_path = File.join(Aircana.configuration.scripts_dir, "sync_local_knowledge.sh")
720
- return if File.exist?(script_path)
721
-
722
- template_path = File.join(File.dirname(__FILE__), "..", "..", "templates", "hooks",
723
- "sync_local_knowledge.erb")
724
- template_content = File.read(template_path)
725
-
726
- FileUtils.mkdir_p(Aircana.configuration.scripts_dir)
727
- File.write(script_path, ERB.new(template_content).result)
728
- File.chmod(0o755, script_path)
729
- end
730
- end
731
- end
732
- end
733
- end