aircana 0.1.0

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/devcontainer.json +36 -0
  3. data/.dockerignore +14 -0
  4. data/.rspec_status +106 -0
  5. data/.rubocop.yml +33 -0
  6. data/CHANGELOG.md +19 -0
  7. data/CLAUDE.md +58 -0
  8. data/Dockerfile +17 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +251 -0
  11. data/Rakefile +12 -0
  12. data/SECURITY.md +15 -0
  13. data/compose.yml +13 -0
  14. data/exe/aircana +13 -0
  15. data/lib/aircana/cli/app.rb +93 -0
  16. data/lib/aircana/cli/commands/add_directory.rb +148 -0
  17. data/lib/aircana/cli/commands/add_files.rb +26 -0
  18. data/lib/aircana/cli/commands/agents.rb +152 -0
  19. data/lib/aircana/cli/commands/clear_files.rb +16 -0
  20. data/lib/aircana/cli/commands/doctor.rb +85 -0
  21. data/lib/aircana/cli/commands/doctor_checks.rb +131 -0
  22. data/lib/aircana/cli/commands/doctor_helpers.rb +119 -0
  23. data/lib/aircana/cli/commands/dump_context.rb +23 -0
  24. data/lib/aircana/cli/commands/generate.rb +34 -0
  25. data/lib/aircana/cli/commands/install.rb +67 -0
  26. data/lib/aircana/cli/commands/plan.rb +69 -0
  27. data/lib/aircana/cli/commands/work.rb +69 -0
  28. data/lib/aircana/cli/shell_command.rb +13 -0
  29. data/lib/aircana/cli/subcommand.rb +19 -0
  30. data/lib/aircana/cli.rb +8 -0
  31. data/lib/aircana/configuration.rb +41 -0
  32. data/lib/aircana/contexts/confluence.rb +141 -0
  33. data/lib/aircana/contexts/confluence_content.rb +36 -0
  34. data/lib/aircana/contexts/confluence_http.rb +41 -0
  35. data/lib/aircana/contexts/confluence_logging.rb +71 -0
  36. data/lib/aircana/contexts/confluence_setup.rb +15 -0
  37. data/lib/aircana/contexts/local.rb +47 -0
  38. data/lib/aircana/contexts/relevant_files.rb +78 -0
  39. data/lib/aircana/fzf_helper.rb +117 -0
  40. data/lib/aircana/generators/agents_generator.rb +75 -0
  41. data/lib/aircana/generators/base_generator.rb +61 -0
  42. data/lib/aircana/generators/helpers.rb +16 -0
  43. data/lib/aircana/generators/relevant_files_command_generator.rb +36 -0
  44. data/lib/aircana/generators/relevant_files_verbose_results_generator.rb +34 -0
  45. data/lib/aircana/generators.rb +10 -0
  46. data/lib/aircana/human_logger.rb +143 -0
  47. data/lib/aircana/initializers.rb +8 -0
  48. data/lib/aircana/llm/claude_client.rb +86 -0
  49. data/lib/aircana/progress_tracker.rb +55 -0
  50. data/lib/aircana/system_checker.rb +177 -0
  51. data/lib/aircana/templates/agents/base_agent.erb +30 -0
  52. data/lib/aircana/templates/agents/defaults/planner.erb +126 -0
  53. data/lib/aircana/templates/agents/defaults/worker.erb +185 -0
  54. data/lib/aircana/templates/commands/add_relevant_files.erb +3 -0
  55. data/lib/aircana/templates/relevant_files_verbose_results.erb +18 -0
  56. data/lib/aircana/version.rb +5 -0
  57. data/lib/aircana.rb +53 -0
  58. data/sig/aircana.rbs +4 -0
  59. metadata +189 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ module Contexts
5
+ module ConfluenceLogging
6
+ private
7
+
8
+ def log_request(method, path, query_params = nil, pagination: false)
9
+ config = Aircana.configuration
10
+ full_url = "#{config.confluence_base_url}#{path}"
11
+
12
+ log_parts = build_request_log_parts(method, full_url, query_params, config)
13
+ log_message = log_parts.join(" | ")
14
+
15
+ output_request_log(log_message, pagination)
16
+ end
17
+
18
+ def log_response(response, context = nil, pagination: false)
19
+ return if pagination
20
+
21
+ status_color = response_status_color(response)
22
+ status_text = response.success? ? "✓" : "✗"
23
+
24
+ log_parts = build_response_log_parts(response, context, status_color, status_text)
25
+
26
+ Aircana.human_logger.info "#{log_parts.join(" | ")}\e[0m"
27
+ end
28
+
29
+ def handle_api_error(operation, error, message)
30
+ Aircana.human_logger.error "Failed to #{operation}: #{error.message}"
31
+ raise Error, "#{message}: #{error.message}"
32
+ end
33
+
34
+ def build_request_log_parts(method, full_url, query_params, config)
35
+ log_parts = ["#{method.upcase} #{full_url}"]
36
+
37
+ if query_params && !query_params.empty?
38
+ query_string = query_params.map { |k, v| "#{k}=#{v}" }.join("&")
39
+ log_parts << "Query: #{query_string}"
40
+ end
41
+
42
+ log_parts << "Auth: Basic #{config.confluence_username}:***"
43
+ log_parts
44
+ end
45
+
46
+ def output_request_log(log_message, pagination)
47
+ if pagination
48
+ print "\r\e[36m🌐 #{log_message}\e[0m\e[K"
49
+ else
50
+ Aircana.human_logger.info log_message
51
+ end
52
+ end
53
+
54
+ def response_status_color(response)
55
+ response.success? ? "32" : "31"
56
+ end
57
+
58
+ def build_response_log_parts(response, context, status_color, status_text)
59
+ log_parts = ["\e[#{status_color}m#{status_text} Response: #{response.code}"]
60
+ log_parts << context if context
61
+
62
+ if response.body && !response.body.empty?
63
+ body_preview = response.body.length > 200 ? "#{response.body[0..200]}..." : response.body
64
+ log_parts << "Body: #{body_preview}"
65
+ end
66
+
67
+ log_parts
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ module Contexts
5
+ module ConfluenceSetup
6
+ def setup_httparty
7
+ config = Aircana.configuration
8
+
9
+ self.class.base_uri config.confluence_base_url
10
+ self.class.basic_auth config.confluence_username, config.confluence_api_token
11
+ self.class.headers "Content-Type" => "application/json"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Aircana
6
+ module Contexts
7
+ class Local
8
+ def store_content(title:, content:, agent:)
9
+ agent_dir = create_agent_knowledge_dir(agent)
10
+ filename = sanitize_filename(title)
11
+ filepath = File.join(agent_dir, "#{filename}.md")
12
+
13
+ File.write(filepath, content)
14
+ Aircana.human_logger.success "Stored '#{title}' for agent '#{agent}' at #{filepath}"
15
+
16
+ filepath
17
+ end
18
+
19
+ private
20
+
21
+ def create_agent_knowledge_dir(agent)
22
+ config = Aircana.configuration
23
+ agent_dir = File.join(config.agent_knowledge_dir, agent, "knowledge")
24
+
25
+ FileUtils.mkdir_p(agent_dir)
26
+
27
+ agent_dir
28
+ end
29
+
30
+ def sanitize_filename(title)
31
+ # Replace invalid characters with safe alternatives
32
+ # Remove or replace characters that are problematic in filenames
33
+ sanitized = title.strip
34
+ .gsub(%r{[<>:"/\\|?*]}, "-") # Replace invalid chars with hyphens
35
+ .gsub(/\s+/, "-") # Replace spaces with hyphens
36
+ .gsub(/-+/, "-") # Collapse multiple hyphens
37
+ .gsub(/^-|-$/, "") # Remove leading/trailing hyphens
38
+
39
+ # Ensure the filename isn't empty and isn't too long
40
+ sanitized = "untitled" if sanitized.empty?
41
+ sanitized = sanitized[0, 200] if sanitized.length > 200 # Limit to 200 chars
42
+
43
+ sanitized
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ module Contexts
5
+ class RelevantFiles
6
+ class << self
7
+ # TODO: Honor the provided verbose flag
8
+ def print(_verbose: false)
9
+ verbose_generator(default_stream: true).generate
10
+ end
11
+
12
+ def add(files)
13
+ files = Array(files)
14
+
15
+ return if files.empty?
16
+
17
+ Aircana.create_dir_if_needed(Aircana.configuration.relevant_project_files_dir)
18
+
19
+ files.each do |file|
20
+ absolute_file_path = File.expand_path(file)
21
+ link_path = "#{Aircana.configuration.relevant_project_files_dir}/#{File.basename(file)}"
22
+
23
+ FileUtils.rm_f(link_path)
24
+ File.symlink(absolute_file_path, link_path)
25
+ end
26
+
27
+ rewrite_verbose_file
28
+ end
29
+
30
+ def remove(files)
31
+ files = Array(files)
32
+
33
+ return if files.empty?
34
+
35
+ Aircana.create_dir_if_needed(Aircana.configuration.relevant_project_files_dir)
36
+
37
+ files.each do |file|
38
+ link_path = "#{Aircana.configuration.relevant_project_files_dir}/#{File.basename(file)}"
39
+ FileUtils.rm_f(link_path)
40
+ end
41
+
42
+ rewrite_verbose_file
43
+ end
44
+
45
+ def remove_all
46
+ return unless directory_exists?
47
+
48
+ Dir.glob("#{Aircana.configuration.relevant_project_files_dir}/*").each do |file|
49
+ FileUtils.rm_f(file)
50
+ end
51
+
52
+ return unless Dir.empty?(Aircana.configuration.relevant_project_files_dir)
53
+
54
+ Dir.rmdir(Aircana.configuration.relevant_project_files_dir)
55
+ end
56
+
57
+ private
58
+
59
+ def rewrite_verbose_file
60
+ verbose_generator.generate
61
+
62
+ # TODO: If the verbose file uses too many tokens, warn and instead use only
63
+ # the summary generatior or do something smart like summarize file contents
64
+ end
65
+
66
+ def verbose_generator(default_stream: false)
67
+ Generators::RelevantFilesVerboseResultsGenerator.new(
68
+ file_out: default_stream ? Aircana.configuration.stream : nil
69
+ )
70
+ end
71
+
72
+ def directory_exists?
73
+ Dir.exist?(Aircana.configuration.relevant_project_files_dir)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ class FzfHelper
5
+ class << self
6
+ def select_files_interactively(header: "Select files", multi: true)
7
+ return [] unless fzf_available?
8
+
9
+ execute_fzf_selection(header: header, multi: multi)
10
+ rescue StandardError => e
11
+ Aircana.human_logger.error "File selection failed: #{e.message}"
12
+ []
13
+ end
14
+
15
+ def fzf_available?
16
+ return true if command_available?("fzf")
17
+
18
+ handle_missing_dependency
19
+ false
20
+ end
21
+
22
+ def execute_fzf_selection(header:, multi:)
23
+ command = build_fzf_command(header: header, multi: multi)
24
+ result = `#{command}`.strip
25
+ return [] if result.empty?
26
+
27
+ result.split("\n").map(&:strip).reject(&:empty?)
28
+ end
29
+
30
+ private
31
+
32
+ def command_available?(command)
33
+ system("which #{command}", out: File::NULL, err: File::NULL)
34
+ end
35
+
36
+ def handle_missing_dependency
37
+ Aircana.human_logger.error "fzf is required but not installed"
38
+ Aircana.human_logger.info "To install fzf:"
39
+ Aircana.human_logger.info " • macOS: brew install fzf"
40
+ Aircana.human_logger.info " • Ubuntu/Debian: apt install fzf"
41
+ Aircana.human_logger.info " • Other: https://github.com/junegunn/fzf#installation"
42
+ end
43
+
44
+ def build_fzf_command(header:, multi:)
45
+ options = base_fzf_options(header: header, multi: multi)
46
+ preview_options = preview_command_options
47
+ key_bindings = key_binding_options
48
+
49
+ "#{generate_file_list_command} | fzf #{options} #{preview_options} #{key_bindings}"
50
+ end
51
+
52
+ def base_fzf_options(header:, multi:)
53
+ options = build_fzf_option_list(multi: multi)
54
+ options += build_fzf_display_options(header: header)
55
+ options.join(" ")
56
+ end
57
+
58
+ def build_fzf_option_list(multi:)
59
+ options = ["--ansi", "--border", "--height=80%", "--layout=reverse", "--info=inline"]
60
+ options << "--multi" if multi
61
+ options
62
+ end
63
+
64
+ def build_fzf_display_options(header:)
65
+ [
66
+ "--header='#{header}'",
67
+ "--header-lines=0",
68
+ "--prompt='❯ '",
69
+ "--pointer='▶'",
70
+ "--marker='✓'"
71
+ ]
72
+ end
73
+
74
+ def preview_command_options
75
+ preview_cmd = preview_command
76
+ return "" if preview_cmd.nil?
77
+
78
+ [
79
+ "--preview='#{preview_cmd}'",
80
+ "--preview-window='right:60%:wrap'",
81
+ "--preview-label='Preview'"
82
+ ].join(" ")
83
+ end
84
+
85
+ def preview_command
86
+ # Try to use bat for syntax highlighting, fall back to head/cat
87
+ if command_available?("bat")
88
+ "bat --color=always --style=header,grid --line-range :50 {}"
89
+ elsif command_available?("head")
90
+ "head -50 {}"
91
+ else
92
+ "cat {}"
93
+ end
94
+ end
95
+
96
+ def key_binding_options
97
+ [
98
+ "--bind='ctrl-a:select-all'",
99
+ "--bind='ctrl-d:deselect-all'",
100
+ "--bind='ctrl-/:toggle-preview'",
101
+ "--bind='ctrl-u:preview-page-up'",
102
+ "--bind='ctrl-n:preview-page-down'",
103
+ "--bind='?:toggle-preview'"
104
+ ].join(" ")
105
+ end
106
+
107
+ def generate_file_list_command
108
+ # Use fd if available for better performance and .gitignore respect
109
+ if command_available?("fd")
110
+ "fd --type f --hidden --exclude .git"
111
+ else
112
+ "find . -type f -not -path '*/\\.git/*' -not -path '*/node_modules/*' -not -path '*/\\.vscode/*'"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../generators"
4
+
5
+ module Aircana
6
+ module Generators
7
+ class AgentsGenerator < BaseGenerator
8
+ attr_reader :agent_name, :short_description, :description, :model, :color, :default_agent
9
+
10
+ AVAILABLE_DEFAULT_AGENTS = %w[planner worker].freeze
11
+
12
+ class << self
13
+ def create_default_agent(agent_name)
14
+ unless AVAILABLE_DEFAULT_AGENTS.include?(agent_name)
15
+ raise ArgumentError, "Unknown default agent: #{agent_name}"
16
+ end
17
+
18
+ new(agent_name: agent_name, default_agent: true).generate
19
+ end
20
+
21
+ def available_default_agents
22
+ AVAILABLE_DEFAULT_AGENTS
23
+ end
24
+ end
25
+
26
+ def initialize( # rubocop:disable Metrics/ParameterLists
27
+ agent_name:, short_description: nil, description: nil, model: nil, color: nil,
28
+ file_in: nil, file_out: nil, default_agent: false
29
+ )
30
+ @agent_name = agent_name
31
+ @short_description = short_description
32
+ @description = description
33
+ @model = model
34
+ @color = color
35
+ @default_agent = default_agent
36
+
37
+ super(
38
+ file_in: file_in || default_template_path,
39
+ file_out: file_out || default_output_path
40
+ )
41
+ end
42
+
43
+ protected
44
+
45
+ def locals
46
+ super.merge({
47
+ relevant_project_files_path:, agent_name:, short_description:, description:,
48
+ model:, color:, knowledge_path:
49
+ })
50
+ end
51
+
52
+ private
53
+
54
+ def default_template_path
55
+ if default_agent
56
+ File.join(File.dirname(__FILE__), "..", "templates", "agents", "defaults", "#{agent_name}.erb")
57
+ else
58
+ File.join(File.dirname(__FILE__), "..", "templates", "agents", "base_agent.erb")
59
+ end
60
+ end
61
+
62
+ def default_output_path
63
+ File.join(Aircana.configuration.claude_code_project_config_path, "agents", "#{agent_name}.md")
64
+ end
65
+
66
+ def relevant_project_files_path
67
+ File.join(Aircana.configuration.relevant_project_files_dir, "relevant_files.md")
68
+ end
69
+
70
+ def knowledge_path
71
+ ".aircana/agents/#{agent_name}/knowledge"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require_relative "helpers"
6
+
7
+ module Aircana
8
+ module Generators
9
+ class BaseGenerator
10
+ attr_reader :file_in, :file_out
11
+
12
+ def initialize(file_in: nil, file_out: nil)
13
+ @file_in = file_in
14
+ @file_out = file_out
15
+ end
16
+
17
+ def generate
18
+ prepare_output_directory
19
+ content = generate_content
20
+ write_content(content)
21
+
22
+ file_out
23
+ end
24
+
25
+ private
26
+
27
+ def prepare_output_directory
28
+ return unless file_out.is_a?(String)
29
+
30
+ FileUtils.mkdir_p(File.dirname(file_out))
31
+ end
32
+
33
+ def generate_content
34
+ erb = ERB.new(template)
35
+ Aircana.human_logger.info "Generating #{file_out} from #{file_in}"
36
+ # Debug info removed for cleaner output
37
+ erb.result_with_hash(locals)
38
+ end
39
+
40
+ def write_content(content)
41
+ if file_out.respond_to?(:write)
42
+ file_out.write(content)
43
+ else
44
+ File.write(file_out, content)
45
+ end
46
+ end
47
+
48
+ protected
49
+
50
+ def locals
51
+ { helpers: Helpers }
52
+ end
53
+
54
+ private
55
+
56
+ def template
57
+ File.read(file_in)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ module Generators
5
+ module Helpers
6
+ class << self
7
+ def model_instructions(instructions, important: false)
8
+ <<~INSTRUCTIONS
9
+ INSTRUCTIONS #{"IMPORTANT" if important}:
10
+ #{instructions}
11
+ INSTRUCTIONS
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../generators"
4
+
5
+ module Aircana
6
+ module Generators
7
+ class RelevantFilesCommandGenerator < BaseGenerator
8
+ def initialize(file_in: nil, file_out: nil)
9
+ super(
10
+ file_in: file_in || default_template_path,
11
+ file_out: file_out || default_output_path
12
+ )
13
+ end
14
+
15
+ protected
16
+
17
+ def locals
18
+ super.merge({ relevant_project_files_path: })
19
+ end
20
+
21
+ private
22
+
23
+ def default_template_path
24
+ File.join(File.dirname(__FILE__), "..", "templates", "commands", "add_relevant_files.erb")
25
+ end
26
+
27
+ def default_output_path
28
+ File.join(Aircana.configuration.output_dir, "commands", "ac-add-relevant-files.md")
29
+ end
30
+
31
+ def relevant_project_files_path
32
+ File.join(Aircana.configuration.relevant_project_files_dir, "relevant_files.md")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../generators"
4
+
5
+ module Aircana
6
+ module Generators
7
+ class RelevantFilesVerboseResultsGenerator < BaseGenerator
8
+ def initialize(file_in: nil, file_out: nil)
9
+ super(
10
+ file_in: file_in || default_template_path,
11
+ file_out: file_out || default_output_path
12
+ )
13
+ end
14
+
15
+ private
16
+
17
+ def locals
18
+ super.merge({ relevant_files: })
19
+ end
20
+
21
+ def relevant_files
22
+ Dir.glob("#{Aircana.configuration.relevant_project_files_dir}/*")
23
+ end
24
+
25
+ def default_template_path
26
+ File.join(File.dirname(__FILE__), "..", "templates", "relevant_files_verbose_results.erb")
27
+ end
28
+
29
+ def default_output_path
30
+ File.join(Aircana.configuration.relevant_project_files_dir, "relevant_files.md")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generators/base_generator"
4
+ require_relative "generators/relevant_files_command_generator"
5
+ require_relative "generators/relevant_files_verbose_results_generator"
6
+
7
+ module Aircana
8
+ module Generators
9
+ end
10
+ end