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.
@@ -13,32 +13,19 @@ require_relative "commands/plan"
13
13
  require_relative "commands/work"
14
14
 
15
15
  require_relative "subcommand"
16
+ require_relative "help_formatter"
16
17
  require_relative "commands/agents"
18
+ require_relative "commands/hooks"
19
+ require_relative "commands/project"
20
+ require_relative "commands/files"
17
21
 
18
22
  module Aircana
19
23
  module CLI
20
24
  # Thor application for the primary cli
21
25
  class App < Thor
22
- package_name "Aircana"
23
-
24
- # TODO: Decide how to represent and store file groups
25
- desc "add-files",
26
- "interactively add files or file groups to the current context. Use tab to mark multiple files."
27
- def add_files
28
- AddFiles.run
29
- end
26
+ include HelpFormatter
30
27
 
31
- desc "add-dir [DIRECTORY_PATH]",
32
- "add all files from the specified directory recursively to the current context"
33
- def add_dir(directory_path)
34
- AddDirectory.run(directory_path)
35
- end
36
-
37
- desc "clear-files",
38
- "Removes all files from the current set of 'relevant files'"
39
- def clear_files
40
- ClearFiles.run
41
- end
28
+ package_name "Aircana"
42
29
 
43
30
  desc "doctor", "Check system health and validate all dependencies"
44
31
  option :verbose, type: :boolean, default: false, desc: "Show detailed information about optional dependencies"
@@ -64,14 +51,26 @@ module Aircana
64
51
  Install.run
65
52
  end
66
53
 
67
- desc "plan", "Launch Claude Code with planner agent for Jira ticket planning"
68
- def plan
69
- Plan.run
70
- end
54
+ class FilesSubcommand < Subcommand
55
+ desc "add", "Interactively add files to current context"
56
+ def add
57
+ Files.add
58
+ end
59
+
60
+ desc "add-dir [DIRECTORY_PATH]", "Add all files from directory to context"
61
+ def add_dir(directory_path)
62
+ Files.add_dir(directory_path)
63
+ end
71
64
 
72
- desc "work", "Launch Claude Code with worker agent for Jira ticket implementation"
73
- def work
74
- Work.run
65
+ desc "clear", "Remove all files from current context"
66
+ def clear
67
+ Files.clear
68
+ end
69
+
70
+ desc "list", "Show current relevant files"
71
+ def list
72
+ Files.list
73
+ end
75
74
  end
76
75
 
77
76
  class AgentsSubcommand < Subcommand
@@ -84,10 +83,78 @@ module Aircana
84
83
  def refresh(agent)
85
84
  Agents.refresh(agent)
86
85
  end
86
+
87
+ desc "list", "List all configured agents"
88
+ def list
89
+ Agents.list
90
+ end
91
+ end
92
+
93
+ class HooksSubcommand < Subcommand
94
+ desc "list", "List all available and installed hooks"
95
+ def list
96
+ Hooks.list
97
+ end
98
+
99
+ desc "enable HOOK_NAME", "Enable a specific hook"
100
+ def enable(hook_name)
101
+ Hooks.enable(hook_name)
102
+ end
103
+
104
+ desc "disable HOOK_NAME", "Disable a specific hook"
105
+ def disable(hook_name)
106
+ Hooks.disable(hook_name)
107
+ end
108
+
109
+ desc "create", "Create a new custom hook"
110
+ def create
111
+ Hooks.create
112
+ end
113
+
114
+ desc "status", "Show current hook configuration status"
115
+ def status
116
+ Hooks.status
117
+ end
87
118
  end
88
119
 
120
+ class ProjectSubcommand < Subcommand
121
+ desc "init", "Initialize project.json for multi-root support"
122
+ def init
123
+ Project.init
124
+ end
125
+
126
+ desc "add FOLDER_PATH", "Add a folder to multi-root configuration"
127
+ def add(folder_path)
128
+ Project.add(folder_path)
129
+ end
130
+
131
+ desc "remove FOLDER_PATH", "Remove a folder from multi-root configuration"
132
+ def remove(folder_path)
133
+ Project.remove(folder_path)
134
+ end
135
+
136
+ desc "list", "List all configured folders and their agents"
137
+ def list
138
+ Project.list
139
+ end
140
+
141
+ desc "sync", "Manually sync symlinks for multi-root agents"
142
+ def sync
143
+ Project.sync
144
+ end
145
+ end
146
+
147
+ desc "files", "Manage relevant files for context"
148
+ subcommand "files", FilesSubcommand
149
+
89
150
  desc "agents", "Create and manage agents and their knowledgebases"
90
151
  subcommand "agents", AgentsSubcommand
152
+
153
+ desc "hooks", "Manage Claude Code hooks"
154
+ subcommand "hooks", HooksSubcommand
155
+
156
+ desc "project", "Manage multi-root project configuration"
157
+ subcommand "project", ProjectSubcommand
91
158
  end
92
159
  end
93
160
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "tty-prompt"
4
5
  require_relative "../../generators/agents_generator"
6
+ require_relative "../../contexts/manifest"
5
7
 
6
8
  module Aircana
7
9
  module CLI
@@ -12,7 +14,7 @@ module Aircana
12
14
  class << self # rubocop:disable Metrics/ClassLength
13
15
  def refresh(agent)
14
16
  normalized_agent = normalize_string(agent)
15
- perform_refresh(normalized_agent)
17
+ perform_manifest_aware_refresh(normalized_agent)
16
18
  rescue Aircana::Error => e
17
19
  handle_refresh_error(normalized_agent, e)
18
20
  end
@@ -47,13 +49,24 @@ module Aircana
47
49
  Aircana.human_logger.success "Agent '#{agent_name}' setup complete!"
48
50
  end
49
51
 
52
+ def list
53
+ agent_dir = Aircana.configuration.agent_knowledge_dir
54
+ return print_no_agents_message unless Dir.exist?(agent_dir)
55
+
56
+ agent_folders = find_agent_folders(agent_dir)
57
+ return print_no_agents_message if agent_folders.empty?
58
+
59
+ print_agents_list(agent_folders)
60
+ end
61
+
50
62
  private
51
63
 
52
64
  def perform_refresh(normalized_agent)
53
65
  confluence = Aircana::Contexts::Confluence.new
54
- pages_count = confluence.fetch_pages_for(agent: normalized_agent)
66
+ result = confluence.fetch_pages_for(agent: normalized_agent)
55
67
 
56
- log_refresh_result(normalized_agent, pages_count)
68
+ log_refresh_result(normalized_agent, result[:pages_count])
69
+ result
57
70
  end
58
71
 
59
72
  def log_refresh_result(normalized_agent, pages_count)
@@ -64,6 +77,31 @@ module Aircana
64
77
  end
65
78
  end
66
79
 
80
+ def perform_manifest_aware_refresh(normalized_agent)
81
+ confluence = Aircana::Contexts::Confluence.new
82
+
83
+ # Try manifest-based refresh first
84
+ if Aircana::Contexts::Manifest.manifest_exists?(normalized_agent)
85
+ Aircana.human_logger.info "Refreshing from knowledge manifest..."
86
+ result = confluence.refresh_from_manifest(agent: normalized_agent)
87
+ else
88
+ Aircana.human_logger.info "No manifest found, falling back to label-based search..."
89
+ result = confluence.fetch_pages_for(agent: normalized_agent)
90
+ end
91
+
92
+ log_refresh_result(normalized_agent, result[:pages_count])
93
+ result
94
+ end
95
+
96
+ def show_gitignore_recommendation
97
+ Aircana.human_logger.info ""
98
+ Aircana.human_logger.info "💡 Recommendation: Add knowledge directories to .gitignore:"
99
+ Aircana.human_logger.info " echo \".aircana/agents/*/knowledge/\" >> .gitignore"
100
+ Aircana.human_logger.info ""
101
+ Aircana.human_logger.info " This keeps knowledge sources in version control while excluding"
102
+ Aircana.human_logger.info " the actual knowledge content from your repository."
103
+ end
104
+
67
105
  def log_no_pages_found(normalized_agent)
68
106
  Aircana.human_logger.info "No pages found for agent '#{normalized_agent}'. " \
69
107
  "Make sure pages are labeled with '#{normalized_agent}' in Confluence."
@@ -103,7 +141,8 @@ module Aircana
103
141
 
104
142
  if prompt.yes?("Would you like to fetch knowledge for this agent from Confluence now?")
105
143
  Aircana.human_logger.info "Fetching knowledge from Confluence..."
106
- perform_refresh(normalized_agent_name)
144
+ result = perform_refresh(normalized_agent_name)
145
+ show_gitignore_recommendation if result[:pages_count]&.positive?
107
146
  else
108
147
  Aircana.human_logger.info(
109
148
  "Skipping knowledge fetch. You can run 'aircana agents refresh #{normalized_agent_name}' later."
@@ -143,6 +182,38 @@ module Aircana
143
182
  end
144
183
  end
145
184
 
185
+ def print_no_agents_message
186
+ Aircana.human_logger.info("No agents configured yet.")
187
+ end
188
+
189
+ def find_agent_folders(agent_dir)
190
+ Dir.entries(agent_dir).select do |entry|
191
+ path = File.join(agent_dir, entry)
192
+ File.directory?(path) && !entry.start_with?(".")
193
+ end
194
+ end
195
+
196
+ def print_agents_list(agent_folders)
197
+ Aircana.human_logger.info("Configured agents:")
198
+ agent_folders.each_with_index do |agent_name, index|
199
+ description = get_agent_description(agent_name)
200
+ Aircana.human_logger.info(" #{index + 1}. #{agent_name} - #{description}")
201
+ end
202
+ Aircana.human_logger.info("\nTotal: #{agent_folders.length} agents")
203
+ end
204
+
205
+ def get_agent_description(agent_name)
206
+ agent_config_path = File.join(
207
+ Aircana.configuration.agent_knowledge_dir,
208
+ agent_name,
209
+ "agent.json"
210
+ )
211
+ return "Configuration incomplete" unless File.exist?(agent_config_path)
212
+
213
+ config = JSON.parse(File.read(agent_config_path))
214
+ config["description"] || "No description available"
215
+ end
216
+
146
217
  def find_available_editor
147
218
  %w[code subl atom nano vim vi].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
148
219
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "add_files"
4
+ require_relative "add_directory"
5
+ require_relative "clear_files"
6
+ require_relative "../../contexts/relevant_files"
7
+
8
+ module Aircana
9
+ module CLI
10
+ module Files
11
+ class << self
12
+ def add
13
+ AddFiles.run
14
+ end
15
+
16
+ def add_dir(directory_path)
17
+ AddDirectory.run(directory_path)
18
+ end
19
+
20
+ def clear
21
+ ClearFiles.run
22
+ end
23
+
24
+ def list
25
+ relevant_files_dir = Aircana.configuration.relevant_project_files_dir
26
+ return print_no_directory_message unless Dir.exist?(relevant_files_dir)
27
+
28
+ files = get_tracked_files(relevant_files_dir)
29
+ return print_no_files_message if files.empty?
30
+
31
+ print_files_list(files)
32
+ end
33
+
34
+ private
35
+
36
+ def print_no_directory_message
37
+ Aircana.human_logger.info(
38
+ "No relevant files directory found. Use 'aircana files add' to start tracking files."
39
+ )
40
+ end
41
+
42
+ def print_no_files_message
43
+ Aircana.human_logger.info("No relevant files currently tracked.")
44
+ end
45
+
46
+ def get_tracked_files(relevant_files_dir)
47
+ Dir.glob("#{relevant_files_dir}/*").map do |link|
48
+ File.readlink(link)
49
+ rescue StandardError
50
+ link
51
+ end
52
+ end
53
+
54
+ def print_files_list(files)
55
+ Aircana.human_logger.info("Current relevant files:")
56
+ files.each_with_index do |file, index|
57
+ relative_path = file.start_with?(Dir.pwd) ? file.gsub("#{Dir.pwd}/", "") : file
58
+ Aircana.human_logger.info(" #{index + 1}. #{relative_path}")
59
+ end
60
+ Aircana.human_logger.info("\nTotal: #{files.length} files")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../../generators/relevant_files_command_generator"
4
4
  require_relative "../../generators/relevant_files_verbose_results_generator"
5
5
  require_relative "../../generators/agents_generator"
6
+ require_relative "../../generators/hooks_generator"
7
+ require_relative "../../generators/project_config_generator"
6
8
 
7
9
  module Aircana
8
10
  module CLI
@@ -18,6 +20,8 @@ module Aircana
18
20
  def run
19
21
  generators.each(&:generate)
20
22
  generate_default_agents
23
+ generate_default_hooks
24
+ generate_project_config
21
25
  Aircana.human_logger.success("Re-generated #{Aircana.configuration.output_dir} files.")
22
26
  end
23
27
 
@@ -28,6 +32,21 @@ module Aircana
28
32
  Aircana::Generators::AgentsGenerator.create_default_agent(agent_name)
29
33
  end
30
34
  end
35
+
36
+ def generate_default_hooks
37
+ Aircana::Generators::HooksGenerator.available_default_hooks.each do |hook_name|
38
+ Aircana::Generators::HooksGenerator.create_default_hook(hook_name)
39
+ end
40
+ end
41
+
42
+ def generate_project_config
43
+ project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
44
+
45
+ # Only generate if it doesn't already exist
46
+ return if File.exist?(project_json_path)
47
+
48
+ Aircana::Generators::ProjectConfigGenerator.new.generate
49
+ end
31
50
  end
32
51
  end
33
52
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty-prompt"
5
+ require_relative "../../generators/hooks_generator"
6
+ require_relative "install"
7
+
8
+ module Aircana
9
+ module CLI
10
+ module Hooks
11
+ class << self
12
+ def list
13
+ available_hooks = Aircana::Generators::HooksGenerator.all_available_hooks
14
+ installed_hooks_list = installed_hooks
15
+
16
+ if available_hooks.empty?
17
+ Aircana.human_logger.info "No hooks available."
18
+ return
19
+ end
20
+
21
+ Aircana.human_logger.info "Available Hooks:"
22
+ available_hooks.each do |hook_name|
23
+ status = installed_hooks_list.include?(hook_name) ? "[INSTALLED]" : "[AVAILABLE]"
24
+ description = hook_description(hook_name)
25
+ is_default = Aircana::Generators::HooksGenerator.available_default_hooks.include?(hook_name)
26
+ default_marker = is_default ? " (default)" : ""
27
+ Aircana.human_logger.info " #{status} #{hook_name} - #{description}#{default_marker}"
28
+ end
29
+ end
30
+
31
+ def enable(hook_name)
32
+ unless Aircana::Generators::HooksGenerator.all_available_hooks.include?(hook_name)
33
+ Aircana.human_logger.error "Hook '#{hook_name}' is not available."
34
+ available_hooks_list = Aircana::Generators::HooksGenerator.all_available_hooks.join(", ")
35
+ Aircana.human_logger.info "Available hooks: #{available_hooks_list}"
36
+ return
37
+ end
38
+
39
+ # Generate the hook if it doesn't exist
40
+ Aircana::Generators::HooksGenerator.create_default_hook(hook_name)
41
+
42
+ # Install hooks to Claude settings
43
+ Install.run
44
+
45
+ Aircana.human_logger.success "Hook '#{hook_name}' has been enabled."
46
+ end
47
+
48
+ def disable(hook_name)
49
+ hook_file = File.join(Aircana.configuration.hooks_dir, "#{hook_name}.sh")
50
+
51
+ unless File.exist?(hook_file)
52
+ Aircana.human_logger.warn "Hook '#{hook_name}' is not currently enabled."
53
+ return
54
+ end
55
+
56
+ File.delete(hook_file)
57
+
58
+ # Reinstall remaining hooks to update Claude settings
59
+ Install.run
60
+
61
+ Aircana.human_logger.success "Hook '#{hook_name}' has been disabled."
62
+ end
63
+
64
+ def create
65
+ prompt = TTY::Prompt.new
66
+
67
+ hook_name = prompt.ask("Hook name (lowercase, no spaces):")
68
+ hook_name = hook_name.strip.downcase.gsub(" ", "_")
69
+
70
+ hook_event = prompt.select("Select hook event:", %w[
71
+ pre_tool_use
72
+ post_tool_use
73
+ user_prompt_submit
74
+ session_start
75
+ ])
76
+
77
+ description = prompt.ask("Brief description of what this hook does:")
78
+
79
+ # Ensure custom hook names include the event type for proper mapping
80
+ # unless the name already contains it
81
+ hook_name = "#{hook_name}_#{hook_event}" unless hook_name.include?(hook_event.gsub("_", ""))
82
+
83
+ create_custom_hook(hook_name, hook_event, description)
84
+ end
85
+
86
+ def status
87
+ settings_file = File.join(Aircana.configuration.claude_code_project_config_path, "settings.local.json")
88
+
89
+ unless File.exist?(settings_file)
90
+ Aircana.human_logger.info "No Claude settings file found at #{settings_file}"
91
+ return
92
+ end
93
+
94
+ begin
95
+ settings = JSON.parse(File.read(settings_file))
96
+ hooks_config = settings["hooks"]
97
+
98
+ if hooks_config.nil? || hooks_config.empty?
99
+ Aircana.human_logger.info "No hooks configured in Claude settings."
100
+ else
101
+ Aircana.human_logger.info "Configured hooks in Claude settings:"
102
+ hooks_config.each do |event, configs|
103
+ configs = [configs] unless configs.is_a?(Array)
104
+ configs.each do |config|
105
+ next unless config["hooks"] && config["hooks"][0] && config["hooks"][0]["command"]
106
+
107
+ command_path = config["hooks"][0]["command"]
108
+ script_name = File.basename(command_path, ".sh")
109
+ matcher_info = config["matcher"] ? " (matcher: #{config["matcher"]})" : ""
110
+ Aircana.human_logger.info " #{event}: #{script_name}#{matcher_info}"
111
+ end
112
+ end
113
+ end
114
+ rescue JSON::ParserError => e
115
+ Aircana.human_logger.error "Invalid JSON in settings file: #{e.message}"
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def installed_hooks
122
+ return [] unless Dir.exist?(Aircana.configuration.hooks_dir)
123
+
124
+ Dir.glob("#{Aircana.configuration.hooks_dir}/*.sh").map do |file|
125
+ File.basename(file, ".sh")
126
+ end
127
+ end
128
+
129
+ def hook_description(hook_name)
130
+ descriptions = {
131
+ "pre_tool_use" => "General pre-tool validation hook",
132
+ "post_tool_use" => "General post-tool processing hook",
133
+ "user_prompt_submit" => "Add context to user prompts",
134
+ "session_start" => "Initialize session with project context",
135
+ "rubocop_pre_commit" => "Run RuboCop before git commits",
136
+ "rspec_test" => "Run RSpec tests when Ruby files are modified",
137
+ "bundle_install" => "Run bundle install when Gemfile changes"
138
+ }
139
+ descriptions[hook_name] || "Custom hook"
140
+ end
141
+
142
+ def create_custom_hook(hook_name, hook_event, description)
143
+ template_content = generate_custom_hook_template(hook_event, description)
144
+
145
+ hook_file = File.join(Aircana.configuration.hooks_dir, "#{hook_name}.sh")
146
+ Aircana.create_dir_if_needed(File.dirname(hook_file))
147
+
148
+ File.write(hook_file, template_content)
149
+ File.chmod(0o755, hook_file)
150
+
151
+ Aircana.human_logger.success "Custom hook created at #{hook_file}"
152
+ Aircana.human_logger.info "You may need to customize the hook script for your specific needs."
153
+
154
+ # Install hooks to Claude settings
155
+ Install.run
156
+ Aircana.human_logger.success "Hook installed to Claude settings"
157
+
158
+ # Optionally offer to open in editor
159
+ prompt = TTY::Prompt.new
160
+ return unless prompt.yes?("Would you like to edit the hook file now?")
161
+
162
+ open_file_in_editor(hook_file)
163
+ end
164
+
165
+ def generate_custom_hook_template(hook_event, description)
166
+ case hook_event
167
+ when "pre_tool_use"
168
+ generate_pre_tool_use_template(description)
169
+ when "post_tool_use"
170
+ generate_post_tool_use_template(description)
171
+ when "user_prompt_submit"
172
+ generate_user_prompt_submit_template(description)
173
+ when "session_start"
174
+ generate_session_start_template(description)
175
+ else
176
+ generate_basic_template(hook_event, description)
177
+ end
178
+ end
179
+
180
+ def generate_pre_tool_use_template(description)
181
+ <<~SCRIPT
182
+ #!/bin/bash
183
+ # Custom pre-tool-use hook: #{description}
184
+
185
+ TOOL_NAME="$1"
186
+ TOOL_PARAMS="$2"
187
+
188
+ # Add your custom validation logic here
189
+ # Return exit code 0 to allow, exit code 1 to deny
190
+
191
+ echo "Pre-tool validation: $TOOL_NAME"
192
+
193
+ # Allow by default
194
+ exit 0
195
+ SCRIPT
196
+ end
197
+
198
+ def generate_post_tool_use_template(description)
199
+ <<~SCRIPT
200
+ #!/bin/bash
201
+ # Custom post-tool-use hook: #{description}
202
+
203
+ TOOL_NAME="$1"
204
+ TOOL_PARAMS="$2"
205
+ TOOL_RESULT="$3"
206
+ EXIT_CODE="$4"
207
+
208
+ # Add your custom post-processing logic here
209
+
210
+ echo "Post-tool processing: $TOOL_NAME (exit code: $EXIT_CODE)"
211
+
212
+ # Always allow result to proceed
213
+ exit 0
214
+ SCRIPT
215
+ end
216
+
217
+ def generate_user_prompt_submit_template(description)
218
+ <<~SCRIPT
219
+ #!/bin/bash
220
+ # Custom user prompt submit hook: #{description}
221
+
222
+ USER_PROMPT="$1"
223
+
224
+ # Add custom context or modify the prompt here
225
+ # Output JSON for advanced control or simple exit code
226
+
227
+ echo "Processing user prompt"
228
+
229
+ # Simple allow - no modifications
230
+ exit 0
231
+ SCRIPT
232
+ end
233
+
234
+ def generate_session_start_template(description)
235
+ <<~SCRIPT
236
+ #!/bin/bash
237
+ # Custom session start hook: #{description}
238
+
239
+ # Add session initialization logic here
240
+
241
+ echo "Session started in $(pwd)"
242
+
243
+ # Simple allow
244
+ exit 0
245
+ SCRIPT
246
+ end
247
+
248
+ def generate_basic_template(hook_event, description)
249
+ <<~SCRIPT
250
+ #!/bin/bash
251
+ # Custom #{hook_event} hook: #{description}
252
+
253
+ # Add your custom hook logic here
254
+
255
+ exit 0
256
+ SCRIPT
257
+ end
258
+
259
+ def open_file_in_editor(file_path)
260
+ editor = ENV["EDITOR"] || find_available_editor
261
+
262
+ if editor
263
+ Aircana.human_logger.info "Opening #{file_path} in #{editor}..."
264
+ system("#{editor} '#{file_path}'")
265
+ else
266
+ Aircana.human_logger.warn "No editor found. Please edit #{file_path} manually."
267
+ end
268
+ end
269
+
270
+ def find_available_editor
271
+ %w[code subl atom nano vim vi].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end