aircana 0.1.0 → 1.1.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.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "generate"
5
+ require_relative "../../generators/project_config_generator"
4
6
 
5
7
  module Aircana
6
8
  module CLI
@@ -8,7 +10,9 @@ module Aircana
8
10
  class << self
9
11
  def run
10
12
  ensure_output_exists
13
+ ensure_project_config_exists
11
14
  install_commands_to_claude
15
+ install_hooks_to_claude
12
16
  end
13
17
 
14
18
  private
@@ -20,6 +24,14 @@ module Aircana
20
24
  Generate.run
21
25
  end
22
26
 
27
+ def ensure_project_config_exists
28
+ project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
29
+ return if File.exist?(project_json_path)
30
+
31
+ Aircana.human_logger.info("Creating project.json for multi-root support...")
32
+ Aircana::Generators::ProjectConfigGenerator.new.generate
33
+ end
34
+
23
35
  def install_commands_to_claude
24
36
  claude_commands_dir = File.join(Aircana.configuration.claude_code_project_config_path, "commands")
25
37
  Aircana.create_dir_if_needed(claude_commands_dir)
@@ -61,6 +73,107 @@ module Aircana
61
73
  require_relative "../../generators/agents_generator"
62
74
  Aircana::Generators::AgentsGenerator.available_default_agents.include?(agent_name)
63
75
  end
76
+
77
+ def install_hooks_to_claude
78
+ return unless Dir.exist?(Aircana.configuration.hooks_dir)
79
+
80
+ settings_file = File.join(Aircana.configuration.claude_code_project_config_path, "settings.local.json")
81
+ install_hooks_to_settings(settings_file)
82
+ end
83
+
84
+ def install_hooks_to_settings(settings_file)
85
+ settings = load_settings(settings_file)
86
+ hook_configs = build_hook_configs
87
+
88
+ return if hook_configs.empty?
89
+
90
+ settings["hooks"] = hook_configs
91
+ save_settings(settings_file, settings)
92
+
93
+ Aircana.human_logger.success("Installed hooks to #{settings_file}")
94
+ end
95
+
96
+ def load_settings(settings_file)
97
+ if File.exist?(settings_file)
98
+ JSON.parse(File.read(settings_file))
99
+ else
100
+ Aircana.create_dir_if_needed(File.dirname(settings_file))
101
+ {}
102
+ end
103
+ rescue JSON::ParserError
104
+ Aircana.human_logger.warn("Invalid JSON in #{settings_file}, creating new settings")
105
+ {}
106
+ end
107
+
108
+ def save_settings(settings_file, settings)
109
+ File.write(settings_file, JSON.pretty_generate(settings))
110
+ end
111
+
112
+ def build_hook_configs
113
+ hooks = {}
114
+
115
+ # Map hook files to Claude Code hook events and their properties
116
+ hook_mappings = {
117
+ "pre_tool_use" => { event: "PreToolUse", matcher: nil },
118
+ "post_tool_use" => { event: "PostToolUse", matcher: nil },
119
+ "user_prompt_submit" => { event: "UserPromptSubmit", matcher: nil },
120
+ "session_start" => { event: "SessionStart", matcher: nil },
121
+ "rubocop_pre_commit" => { event: "PreToolUse", matcher: "Bash" },
122
+ "rspec_test" => { event: "PostToolUse", matcher: "Bash" },
123
+ "bundle_install" => { event: "PostToolUse", matcher: "Bash" }
124
+ }
125
+
126
+ Dir.glob("#{Aircana.configuration.hooks_dir}/*.sh").each do |hook_file|
127
+ hook_name = File.basename(hook_file, ".sh")
128
+
129
+ # Determine mapping for this hook
130
+ mapping = if hook_mappings.key?(hook_name)
131
+ hook_mappings[hook_name]
132
+ else
133
+ # For custom hooks, try to infer the event type from the filename
134
+ infer_hook_mapping(hook_name)
135
+ end
136
+
137
+ next unless mapping
138
+
139
+ event_key = mapping[:event]
140
+
141
+ # Create relative path from project root
142
+ relative_path = File.join(".aircana", "hooks", "#{hook_name}.sh")
143
+
144
+ hook_entry = {
145
+ "hooks" => [
146
+ {
147
+ "type" => "command",
148
+ "command" => relative_path
149
+ }
150
+ ]
151
+ }
152
+
153
+ # Add matcher if specified
154
+ hook_entry["matcher"] = mapping[:matcher] if mapping[:matcher]
155
+
156
+ hooks[event_key] ||= []
157
+ hooks[event_key] << hook_entry
158
+ end
159
+
160
+ hooks
161
+ end
162
+
163
+ def infer_hook_mapping(hook_name)
164
+ # Try to infer the event type from common patterns in the hook name
165
+ case hook_name
166
+ when /pre_tool_use|pre_tool|before_tool/i
167
+ { event: "PreToolUse", matcher: nil }
168
+ when /user_prompt|prompt_submit|before_prompt/i
169
+ { event: "UserPromptSubmit", matcher: nil }
170
+ when /session_start|session_init|startup/i
171
+ { event: "SessionStart", matcher: nil }
172
+ else
173
+ # Default to PostToolUse for unknown custom hooks and post_tool patterns
174
+ { event: "PostToolUse", matcher: nil }
175
+ end
176
+ end
64
177
  end
65
178
  end
66
179
  end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty-prompt"
5
+ require_relative "../../symlink_manager"
6
+ require_relative "../../generators/project_config_generator"
7
+
8
+ module Aircana
9
+ module CLI
10
+ module Project
11
+ class << self
12
+ def init
13
+ generator = Aircana::Generators::ProjectConfigGenerator.new
14
+ config_path = generator.generate
15
+
16
+ Aircana.human_logger.success "Initialized project.json at #{config_path}"
17
+ Aircana.human_logger.info "Add folders using: aircana project add <path>"
18
+ end
19
+
20
+ def add(folder_path)
21
+ project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
22
+
23
+ # Create project.json if it doesn't exist
24
+ init unless File.exist?(project_json_path)
25
+
26
+ # Validate folder exists
27
+ full_path = File.join(Aircana.configuration.project_dir, folder_path)
28
+ unless Dir.exist?(full_path)
29
+ Aircana.human_logger.error "Folder not found: #{folder_path}"
30
+ return
31
+ end
32
+
33
+ # Load existing config
34
+ config = JSON.parse(File.read(project_json_path))
35
+ config["folders"] ||= []
36
+
37
+ # Check if folder already exists
38
+ if config["folders"].any? { |f| f["path"] == folder_path }
39
+ Aircana.human_logger.warn "Folder already configured: #{folder_path}"
40
+ return
41
+ end
42
+
43
+ # Add the folder
44
+ config["folders"] << { "path" => folder_path }
45
+
46
+ # Save updated config
47
+ File.write(project_json_path, JSON.pretty_generate(config))
48
+
49
+ Aircana.human_logger.success "Added folder: #{folder_path}"
50
+
51
+ # Check what agents/knowledge would be available
52
+ check_folder_contents(folder_path)
53
+
54
+ # Offer to sync
55
+ prompt = TTY::Prompt.new
56
+ sync if prompt.yes?("Would you like to sync symlinks now?")
57
+ end
58
+
59
+ def remove(folder_path)
60
+ project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
61
+
62
+ unless File.exist?(project_json_path)
63
+ Aircana.human_logger.error "No project.json found. Run 'aircana project init' first."
64
+ return
65
+ end
66
+
67
+ # Load existing config
68
+ config = JSON.parse(File.read(project_json_path))
69
+ config["folders"] ||= []
70
+
71
+ # Remove the folder
72
+ original_count = config["folders"].size
73
+ config["folders"].reject! { |f| f["path"] == folder_path }
74
+
75
+ if config["folders"].size == original_count
76
+ Aircana.human_logger.warn "Folder not found in configuration: #{folder_path}"
77
+ return
78
+ end
79
+
80
+ # Save updated config
81
+ File.write(project_json_path, JSON.pretty_generate(config))
82
+
83
+ Aircana.human_logger.success "Removed folder: #{folder_path}"
84
+
85
+ # Clean up symlinks
86
+ Aircana::SymlinkManager.cleanup_broken_symlinks
87
+ end
88
+
89
+ def list
90
+ project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
91
+
92
+ unless File.exist?(project_json_path)
93
+ Aircana.human_logger.info "No project.json found. Run 'aircana project init' to create one."
94
+ return
95
+ end
96
+
97
+ config = JSON.parse(File.read(project_json_path))
98
+ folders = config["folders"] || []
99
+
100
+ if folders.empty?
101
+ Aircana.human_logger.info "No folders configured."
102
+ Aircana.human_logger.info "Add folders using: aircana project add <path>"
103
+ return
104
+ end
105
+
106
+ Aircana.human_logger.info "Configured folders:"
107
+ folders.each do |folder|
108
+ folder_path = folder["path"]
109
+ status = Dir.exist?(File.join(Aircana.configuration.project_dir, folder_path)) ? "✓" : "✗"
110
+ Aircana.human_logger.info " #{status} #{folder_path}"
111
+
112
+ # Show available agents if folder exists
113
+ check_folder_contents(folder_path, indent: " ") if status == "✓"
114
+ end
115
+ end
116
+
117
+ def sync
118
+ Aircana.human_logger.info "Syncing multi-root project symlinks..."
119
+
120
+ stats = Aircana::SymlinkManager.sync_multi_root_agents
121
+
122
+ if stats[:agents].zero? && stats[:knowledge].zero?
123
+ Aircana.human_logger.info "No agents or knowledge bases to link."
124
+ else
125
+ Aircana.human_logger.success "Sync complete: #{stats[:agents]} agents, #{stats[:knowledge]} knowledge bases"
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def check_folder_contents(folder_path, indent: " ")
132
+ agents_dir = File.join(folder_path, ".claude", "agents")
133
+ knowledge_dir = File.join(folder_path, ".aircana", "agents")
134
+
135
+ agents = []
136
+ knowledge = []
137
+
138
+ agents = Dir.glob("#{agents_dir}/*.md").map { |f| File.basename(f, ".md") } if Dir.exist?(agents_dir)
139
+
140
+ if Dir.exist?(knowledge_dir)
141
+ knowledge = Dir.glob("#{knowledge_dir}/*").select { |d| File.directory?(d) }
142
+ .map { |d| File.basename(d) }
143
+ end
144
+
145
+ Aircana.human_logger.info "#{indent}Agents: #{agents.join(", ")}" if agents.any?
146
+
147
+ Aircana.human_logger.info "#{indent}Knowledge: #{knowledge.join(", ")}" if knowledge.any?
148
+
149
+ return unless agents.empty? && knowledge.empty?
150
+
151
+ Aircana.human_logger.info "#{indent}No agents or knowledge found"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aircana
4
+ module CLI
5
+ # Custom help formatter to organize commands into logical groups
6
+ module HelpFormatter
7
+ def help(command = nil, subcommand: false)
8
+ if command
9
+ super
10
+ else
11
+ print_grouped_commands
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def print_grouped_commands
18
+ say "Aircana - Context Management for Claude Code", :bold
19
+ command_groups.each { |group_name, commands| print_command_group(group_name, commands) }
20
+ say
21
+ say "Use 'aircana help [COMMAND]' for more information on a specific command.", :green
22
+ end
23
+
24
+ def command_groups
25
+ {
26
+ "File Management" => %w[files],
27
+ "Agent Management" => %w[agents],
28
+ "Hook Management" => %w[hooks],
29
+ "Project Management" => %w[project],
30
+ "System" => %w[generate install doctor dump-context]
31
+ }
32
+ end
33
+
34
+ def print_command_group(group_name, group_commands)
35
+ print_group_header(group_name)
36
+ group_commands.each { |cmd_name| print_group_command(cmd_name) }
37
+ end
38
+
39
+ def print_group_header(group_name)
40
+ say
41
+ say "#{group_name}:", :yellow
42
+ end
43
+
44
+ def print_group_command(cmd_name)
45
+ cmd = self.class.commands[cmd_name]
46
+ return unless cmd
47
+
48
+ if subcommand?(cmd_name)
49
+ print_subcommand_group(cmd_name, cmd)
50
+ else
51
+ print_command(cmd)
52
+ end
53
+ end
54
+
55
+ def subcommand?(cmd_name)
56
+ %w[files agents hooks project].include?(cmd_name)
57
+ end
58
+
59
+ def print_subcommand_group(subcommand_name, cmd)
60
+ subcommand_class = get_subcommand_class(subcommand_name)
61
+ return print_command(cmd) unless subcommand_class
62
+
63
+ print_subcommands(subcommand_class, subcommand_name)
64
+ end
65
+
66
+ def get_subcommand_class(subcommand_name)
67
+ class_name = "#{subcommand_name.capitalize}Subcommand"
68
+ return self.class.const_get(class_name) if self.class.const_defined?(class_name)
69
+
70
+ nil
71
+ rescue NameError
72
+ nil
73
+ end
74
+
75
+ def print_subcommands(subcommand_class, subcommand_name)
76
+ subcommand_class.commands.each_value do |sub_cmd|
77
+ usage = "aircana #{subcommand_name} #{sub_cmd.usage}"
78
+ desc = sub_cmd.description
79
+ say " #{usage.ljust(35)} # #{desc}"
80
+ end
81
+ end
82
+
83
+ def print_command(cmd)
84
+ usage = "aircana #{cmd.usage}"
85
+ desc = cmd.description
86
+ say " #{usage.ljust(35)} # #{desc}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -10,7 +10,8 @@ module Aircana
10
10
  end
11
11
 
12
12
  def self.subcommand_prefix
13
- name.gsub(/.*::/, "").gsub(/^[A-Z]/) do |match|
13
+ # Extract the main command name from class name (e.g., "FilesSubcommand" -> "files")
14
+ name.gsub(/.*::/, "").gsub(/Subcommand$/, "").gsub(/^[A-Z]/) do |match|
14
15
  match[0].downcase
15
16
  end.gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" } # rubocop:disable Style/MultilineBlockChain
16
17
  end
@@ -4,7 +4,7 @@ module Aircana
4
4
  class Configuration
5
5
  attr_accessor :global_dir, :project_dir, :relevant_project_files_dir, :stream, :output_dir,
6
6
  :claude_code_config_path, :claude_code_project_config_path, :agent_knowledge_dir,
7
- :confluence_base_url, :confluence_username, :confluence_api_token
7
+ :hooks_dir, :confluence_base_url, :confluence_username, :confluence_api_token
8
8
 
9
9
  def initialize
10
10
  setup_directory_paths
@@ -21,6 +21,7 @@ module Aircana
21
21
  @relevant_project_files_dir = File.join(@project_dir, ".aircana", "relevant_files")
22
22
  @output_dir = File.join(@global_dir, "aircana.out")
23
23
  @agent_knowledge_dir = File.join(@project_dir, ".aircana", "agents")
24
+ @hooks_dir = File.join(@project_dir, ".aircana", "hooks")
24
25
  end
25
26
 
26
27
  def setup_claude_code_paths
@@ -3,6 +3,7 @@
3
3
  require "httparty"
4
4
  require "reverse_markdown"
5
5
  require_relative "local"
6
+ require_relative "manifest"
6
7
  require_relative "confluence_logging"
7
8
  require_relative "confluence_http"
8
9
  require_relative "confluence_content"
@@ -28,10 +29,36 @@ module Aircana
28
29
  setup_httparty
29
30
 
30
31
  pages = search_and_log_pages(agent)
31
- return 0 if pages.empty?
32
+ return { pages_count: 0, sources: [] } if pages.empty?
32
33
 
33
- process_pages(pages, agent)
34
- pages.size
34
+ sources = process_pages_with_manifest(pages, agent)
35
+ create_or_update_manifest(agent, sources)
36
+
37
+ { pages_count: pages.size, sources: sources }
38
+ end
39
+
40
+ def refresh_from_manifest(agent:)
41
+ sources = Manifest.sources_from_manifest(agent)
42
+ return { pages_count: 0, sources: [] } if sources.empty?
43
+
44
+ validate_configuration!
45
+ setup_httparty
46
+
47
+ confluence_sources = sources.select { |s| s["type"] == "confluence" }
48
+ return { pages_count: 0, sources: [] } if confluence_sources.empty?
49
+
50
+ all_pages = []
51
+ confluence_sources.each do |source|
52
+ pages = fetch_pages_from_source(source)
53
+ all_pages.concat(pages)
54
+ end
55
+
56
+ return { pages_count: 0, sources: [] } if all_pages.empty?
57
+
58
+ updated_sources = process_pages_with_manifest(all_pages, agent)
59
+ Manifest.update_manifest(agent, updated_sources)
60
+
61
+ { pages_count: all_pages.size, sources: updated_sources }
35
62
  end
36
63
 
37
64
  def search_and_log_pages(agent)
@@ -48,8 +75,54 @@ module Aircana
48
75
  end
49
76
  end
50
77
 
78
+ def process_pages_with_manifest(pages, agent)
79
+ page_metadata = []
80
+
81
+ ProgressTracker.with_batch_progress(pages, "Processing pages") do |page, _index|
82
+ store_page_as_markdown(page, agent)
83
+ page_metadata << extract_page_metadata(page)
84
+ end
85
+
86
+ build_source_metadata(agent, page_metadata)
87
+ end
88
+
51
89
  private
52
90
 
91
+ def fetch_pages_from_source(source)
92
+ case source["type"]
93
+ when "confluence"
94
+ fetch_pages_by_label(source["label"])
95
+ else
96
+ []
97
+ end
98
+ end
99
+
100
+ def extract_page_metadata(page)
101
+ {
102
+ "id" => page["id"],
103
+ "title" => page["title"],
104
+ "last_updated" => page.dig("version", "when") || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
105
+ }
106
+ end
107
+
108
+ def build_source_metadata(agent, page_metadata)
109
+ [
110
+ {
111
+ "type" => "confluence",
112
+ "label" => agent,
113
+ "pages" => page_metadata
114
+ }
115
+ ]
116
+ end
117
+
118
+ def create_or_update_manifest(agent, sources)
119
+ if Manifest.manifest_exists?(agent)
120
+ Manifest.update_manifest(agent, sources)
121
+ else
122
+ Manifest.create_manifest(agent, sources)
123
+ end
124
+ end
125
+
53
126
  def validate_configuration!
54
127
  config = Aircana.configuration
55
128
 
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aircana
7
+ module Contexts
8
+ class Manifest
9
+ class << self
10
+ def create_manifest(agent, sources)
11
+ validate_sources(sources)
12
+
13
+ manifest_path = manifest_path_for(agent)
14
+ manifest_data = build_manifest_data(agent, sources)
15
+
16
+ FileUtils.mkdir_p(File.dirname(manifest_path))
17
+ File.write(manifest_path, JSON.pretty_generate(manifest_data))
18
+
19
+ Aircana.human_logger.info "Created knowledge manifest for agent '#{agent}'"
20
+ manifest_path
21
+ end
22
+
23
+ def update_manifest(agent, sources)
24
+ validate_sources(sources)
25
+
26
+ manifest_path = manifest_path_for(agent)
27
+
28
+ if File.exist?(manifest_path)
29
+ existing_data = JSON.parse(File.read(manifest_path))
30
+ manifest_data = existing_data.merge({
31
+ "last_updated" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
32
+ "sources" => sources
33
+ })
34
+ else
35
+ manifest_data = build_manifest_data(agent, sources)
36
+ end
37
+
38
+ FileUtils.mkdir_p(File.dirname(manifest_path))
39
+ File.write(manifest_path, JSON.pretty_generate(manifest_data))
40
+ manifest_path
41
+ end
42
+
43
+ def read_manifest(agent)
44
+ manifest_path = manifest_path_for(agent)
45
+ return nil unless File.exist?(manifest_path)
46
+
47
+ begin
48
+ manifest_data = JSON.parse(File.read(manifest_path))
49
+ validate_manifest(manifest_data)
50
+ manifest_data
51
+ rescue JSON::ParserError => e
52
+ Aircana.human_logger.warn "Invalid manifest for agent '#{agent}': #{e.message}"
53
+ nil
54
+ rescue ManifestError => e
55
+ Aircana.human_logger.warn "Manifest validation failed for agent '#{agent}': #{e.message}"
56
+ nil
57
+ end
58
+ end
59
+
60
+ def sources_from_manifest(agent)
61
+ manifest = read_manifest(agent)
62
+ return [] unless manifest
63
+
64
+ manifest["sources"] || []
65
+ end
66
+
67
+ def manifest_exists?(agent)
68
+ File.exist?(manifest_path_for(agent))
69
+ end
70
+
71
+ private
72
+
73
+ def manifest_path_for(agent)
74
+ resolved_agent_path = resolve_agent_path(agent)
75
+ File.join(resolved_agent_path, "manifest.json")
76
+ end
77
+
78
+ def resolve_agent_path(agent)
79
+ base_path = File.join(Aircana.configuration.agent_knowledge_dir, agent)
80
+
81
+ # If this is a symlink (multi-root scenario), resolve to original
82
+ if File.symlink?(base_path)
83
+ File.readlink(base_path)
84
+ else
85
+ base_path
86
+ end
87
+ end
88
+
89
+ def build_manifest_data(agent, sources)
90
+ timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
91
+
92
+ {
93
+ "version" => "1.0",
94
+ "agent" => agent,
95
+ "created" => timestamp,
96
+ "last_updated" => timestamp,
97
+ "sources" => sources
98
+ }
99
+ end
100
+
101
+ def validate_manifest(manifest_data)
102
+ required_fields = %w[version agent sources]
103
+
104
+ required_fields.each do |field|
105
+ raise ManifestError, "Missing required field: #{field}" unless manifest_data.key?(field)
106
+ end
107
+
108
+ unless manifest_data["version"] == "1.0"
109
+ raise ManifestError, "Unsupported manifest version: #{manifest_data["version"]}"
110
+ end
111
+
112
+ validate_sources(manifest_data["sources"])
113
+ end
114
+
115
+ def validate_sources(sources)
116
+ raise ManifestError, "Sources must be an array" unless sources.is_a?(Array)
117
+
118
+ sources.each do |source|
119
+ validate_source(source)
120
+ end
121
+ end
122
+
123
+ def validate_source(source)
124
+ raise ManifestError, "Each source must be a hash" unless source.is_a?(Hash)
125
+
126
+ raise ManifestError, "Source missing required field: type" unless source.key?("type")
127
+
128
+ case source["type"]
129
+ when "confluence"
130
+ validate_confluence_source(source)
131
+ else
132
+ raise ManifestError, "Unknown source type: #{source["type"]}"
133
+ end
134
+ end
135
+
136
+ def validate_confluence_source(source)
137
+ raise ManifestError, "Confluence source missing required field: label" unless source.key?("label")
138
+
139
+ return unless source.key?("pages") && !source["pages"].is_a?(Array)
140
+
141
+ raise ManifestError, "Confluence pages must be an array"
142
+ end
143
+ end
144
+ end
145
+
146
+ class ManifestError < StandardError; end
147
+ end
148
+ end
@@ -68,7 +68,7 @@ module Aircana
68
68
  end
69
69
 
70
70
  def knowledge_path
71
- ".aircana/agents/#{agent_name}/knowledge"
71
+ ".aircana/agents/#{agent_name}/knowledge/"
72
72
  end
73
73
  end
74
74
  end