aircana 3.0.0.rc1 → 3.0.0.rc3

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.claude-plugin/plugin.json +7 -0
  3. data/.rspec_status +150 -153
  4. data/.rubocop.yml +12 -0
  5. data/CHANGELOG.md +24 -2
  6. data/CLAUDE.md +50 -19
  7. data/README.md +132 -55
  8. data/agents/apply_feedback.md +92 -0
  9. data/agents/executor.md +85 -0
  10. data/agents/jira.md +46 -0
  11. data/agents/planner.md +64 -0
  12. data/agents/reviewer.md +95 -0
  13. data/agents/sub-agent-coordinator.md +91 -0
  14. data/agents/test-agent/manifest.json +15 -0
  15. data/commands/air-apply-feedback.md +15 -0
  16. data/commands/air-ask-expert.md +42 -0
  17. data/commands/air-execute.md +13 -0
  18. data/commands/air-plan.md +33 -0
  19. data/commands/air-record.md +17 -0
  20. data/commands/air-review.md +12 -0
  21. data/commands/sample-command.md +1 -0
  22. data/hooks/hooks.json +31 -0
  23. data/lib/aircana/cli/app.rb +32 -4
  24. data/lib/aircana/cli/commands/agents.rb +41 -9
  25. data/lib/aircana/cli/commands/doctor_checks.rb +2 -3
  26. data/lib/aircana/cli/commands/hooks.rb +4 -4
  27. data/lib/aircana/cli/commands/init.rb +261 -0
  28. data/lib/aircana/cli/commands/plugin.rb +157 -0
  29. data/lib/aircana/cli/help_formatter.rb +1 -1
  30. data/lib/aircana/configuration.rb +29 -3
  31. data/lib/aircana/generators/agents_generator.rb +3 -2
  32. data/lib/aircana/hooks_manifest.rb +189 -0
  33. data/lib/aircana/plugin_manifest.rb +146 -0
  34. data/lib/aircana/system_checker.rb +0 -1
  35. data/lib/aircana/templates/agents/base_agent.erb +2 -2
  36. data/lib/aircana/templates/hooks/user_prompt_submit.erb +0 -6
  37. data/lib/aircana/version.rb +1 -1
  38. data/spec_target_1760205040_181/agents/test-agent/manifest.json +15 -0
  39. data/spec_target_1760205220_486/agents/test-agent/manifest.json +15 -0
  40. data/spec_target_1760205379_250/agents/test-agent/manifest.json +15 -0
  41. data/spec_target_1760205601_652/agents/test-agent/manifest.json +15 -0
  42. data/spec_target_1760205608_135/agents/test-agent/manifest.json +15 -0
  43. data/spec_target_1760205654_952/agents/test-agent/manifest.json +15 -0
  44. metadata +27 -2
  45. data/lib/aircana/cli/commands/install.rb +0 -169
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty-prompt"
5
+ require_relative "generate"
6
+ require_relative "../../plugin_manifest"
7
+ require_relative "../../hooks_manifest"
8
+
9
+ module Aircana
10
+ module CLI
11
+ module Init # rubocop:disable Metrics/ModuleLength
12
+ class << self # rubocop:disable Metrics/ClassLength
13
+ def run(directory: nil, plugin_name: nil)
14
+ target_dir = resolve_target_directory(directory)
15
+
16
+ with_directory_config(target_dir) do
17
+ # Collect plugin metadata
18
+ metadata = collect_plugin_metadata(target_dir, plugin_name)
19
+
20
+ # Create plugin structure
21
+ create_plugin_structure(target_dir)
22
+
23
+ # Create plugin manifest
24
+ create_plugin_manifest(target_dir, metadata)
25
+
26
+ # Generate and install commands
27
+ generate_files
28
+ install_commands
29
+
30
+ # Install hooks
31
+ install_hooks
32
+
33
+ # Success message
34
+ display_success_message(metadata)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def resolve_target_directory(directory)
41
+ return Dir.pwd if directory.nil? || directory.empty?
42
+
43
+ dir = File.expand_path(directory)
44
+ FileUtils.mkdir_p(dir)
45
+
46
+ dir
47
+ end
48
+
49
+ def with_directory_config(target_dir)
50
+ original_project_dir = Aircana.configuration.project_dir
51
+ original_plugin_root = Aircana.configuration.plugin_root
52
+
53
+ begin
54
+ # Temporarily override configuration to use target directory
55
+ Aircana.configuration.project_dir = target_dir
56
+ Aircana.configuration.plugin_root = target_dir
57
+ Aircana.configuration.instance_variable_set(:@plugin_manifest_dir,
58
+ File.join(target_dir, ".claude-plugin"))
59
+ Aircana.configuration.instance_variable_set(:@commands_dir, File.join(target_dir, "commands"))
60
+ Aircana.configuration.instance_variable_set(:@agents_dir, File.join(target_dir, "agents"))
61
+ Aircana.configuration.instance_variable_set(:@hooks_dir, File.join(target_dir, "hooks"))
62
+ Aircana.configuration.instance_variable_set(:@agent_knowledge_dir, File.join(target_dir, "agents"))
63
+
64
+ yield
65
+ ensure
66
+ # Restore original configuration
67
+ Aircana.configuration.project_dir = original_project_dir
68
+ Aircana.configuration.plugin_root = original_plugin_root
69
+ Aircana.configuration.instance_variable_set(:@plugin_manifest_dir,
70
+ File.join(original_plugin_root, ".claude-plugin"))
71
+ Aircana.configuration.instance_variable_set(:@commands_dir,
72
+ File.join(original_plugin_root, "commands"))
73
+ Aircana.configuration.instance_variable_set(:@agents_dir, File.join(original_plugin_root, "agents"))
74
+ Aircana.configuration.instance_variable_set(:@hooks_dir, File.join(original_plugin_root, "hooks"))
75
+ Aircana.configuration.instance_variable_set(:@agent_knowledge_dir,
76
+ File.join(original_plugin_root, "agents"))
77
+ end
78
+ end
79
+
80
+ def collect_plugin_metadata(target_dir, plugin_name)
81
+ prompt = TTY::Prompt.new
82
+
83
+ default_name = plugin_name || PluginManifest.default_plugin_name(target_dir)
84
+
85
+ {
86
+ name: prompt.ask("Plugin name:", default: default_name),
87
+ version: prompt.ask("Initial version:", default: "0.1.0"),
88
+ description: prompt.ask("Plugin description:", default: "A Claude Code plugin created with Aircana"),
89
+ author: prompt.ask("Author name:"),
90
+ license: prompt.ask("License:", default: "MIT")
91
+ }
92
+ end
93
+
94
+ def create_plugin_structure(target_dir)
95
+ # Create plugin directories
96
+ [".claude-plugin", "commands", "agents", "hooks"].each do |dir|
97
+ dir_path = File.join(target_dir, dir)
98
+ Aircana.create_dir_if_needed(dir_path)
99
+ Aircana.human_logger.info("Created directory: #{dir}/")
100
+ end
101
+ end
102
+
103
+ def create_plugin_manifest(target_dir, metadata)
104
+ manifest = PluginManifest.new(target_dir)
105
+ manifest.create(metadata)
106
+ Aircana.human_logger.success("Created plugin manifest at .claude-plugin/plugin.json")
107
+ end
108
+
109
+ def generate_files
110
+ Aircana.human_logger.info("Generating files...")
111
+ Generate.run
112
+ end
113
+
114
+ def install_commands
115
+ commands_dir = Aircana.configuration.commands_dir
116
+ Aircana.create_dir_if_needed(commands_dir)
117
+
118
+ copy_command_files(commands_dir)
119
+ install_default_agents
120
+ end
121
+
122
+ def copy_command_files(destination_dir)
123
+ Dir.glob("#{Aircana.configuration.output_dir}/commands/*").each do |file|
124
+ Aircana.human_logger.success("Installing command: #{File.basename(file)}")
125
+ FileUtils.cp(file, destination_dir)
126
+ end
127
+ end
128
+
129
+ def install_default_agents
130
+ agents_dir = Aircana.configuration.agents_dir
131
+ Aircana.create_dir_if_needed(agents_dir)
132
+
133
+ copy_agent_files(agents_dir)
134
+ end
135
+
136
+ def copy_agent_files(destination_dir)
137
+ agent_files_pattern = File.join(Aircana.configuration.output_dir, "agents", "*.md")
138
+ Dir.glob(agent_files_pattern).each do |file|
139
+ agent_name = File.basename(file, ".md")
140
+ next unless default_agent?(agent_name)
141
+
142
+ destination_file = File.join(destination_dir, File.basename(file))
143
+ # Skip copying if source and destination are the same
144
+ next if File.expand_path(file) == File.expand_path(destination_file)
145
+
146
+ Aircana.human_logger.success("Installing default agent: #{agent_name}")
147
+ FileUtils.cp(file, destination_dir)
148
+ end
149
+ end
150
+
151
+ def default_agent?(agent_name)
152
+ require_relative "../../generators/agents_generator"
153
+ Aircana::Generators::AgentsGenerator.available_default_agents.include?(agent_name)
154
+ end
155
+
156
+ def install_hooks
157
+ hooks_dir = Aircana.configuration.hooks_dir
158
+ return unless Dir.exist?(hooks_dir)
159
+
160
+ # Check if any hook scripts exist (they're already generated to the correct location)
161
+ hook_files = Dir.glob("#{hooks_dir}/*.sh")
162
+ return unless hook_files.any?
163
+
164
+ # Create hooks manifest
165
+ create_hooks_manifest
166
+ end
167
+
168
+ def create_hooks_manifest
169
+ hooks_config = build_hooks_config
170
+
171
+ return if hooks_config.empty?
172
+
173
+ manifest = HooksManifest.new(Aircana.configuration.plugin_root)
174
+ manifest.create(hooks_config)
175
+
176
+ Aircana.human_logger.success("Created hooks manifest at hooks/hooks.json")
177
+ end
178
+
179
+ def build_hooks_config
180
+ hooks = {}
181
+
182
+ # Map hook files to Claude Code hook events and their properties
183
+ hook_mappings = {
184
+ "pre_tool_use" => { event: "PreToolUse", matcher: nil },
185
+ "post_tool_use" => { event: "PostToolUse", matcher: nil },
186
+ "user_prompt_submit" => { event: "UserPromptSubmit", matcher: nil },
187
+ "session_start" => { event: "SessionStart", matcher: nil },
188
+ "notification_sqs" => { event: "Notification", matcher: nil },
189
+ "rubocop_pre_commit" => { event: "PreToolUse", matcher: "Bash" },
190
+ "rspec_test" => { event: "PostToolUse", matcher: "Bash" },
191
+ "bundle_install" => { event: "PostToolUse", matcher: "Bash" }
192
+ }
193
+
194
+ Dir.glob("#{Aircana.configuration.hooks_dir}/*.sh").each do |hook_file|
195
+ hook_name = File.basename(hook_file, ".sh")
196
+
197
+ # Determine mapping for this hook
198
+ mapping = if hook_mappings.key?(hook_name)
199
+ hook_mappings[hook_name]
200
+ else
201
+ # For custom hooks, try to infer the event type from the filename
202
+ infer_hook_mapping(hook_name)
203
+ end
204
+
205
+ next unless mapping
206
+
207
+ event_key = mapping[:event]
208
+
209
+ # Create relative path using ${CLAUDE_PLUGIN_ROOT}
210
+ relative_path = "${CLAUDE_PLUGIN_ROOT}/hooks/#{hook_name}.sh"
211
+
212
+ hook_entry = {
213
+ "type" => "command",
214
+ "command" => relative_path
215
+ }
216
+
217
+ hook_config = {
218
+ "hooks" => [hook_entry]
219
+ }
220
+
221
+ # Add matcher if specified
222
+ hook_config["matcher"] = mapping[:matcher] if mapping[:matcher]
223
+
224
+ hooks[event_key] ||= []
225
+ hooks[event_key] << hook_config
226
+ end
227
+
228
+ hooks
229
+ end
230
+
231
+ def infer_hook_mapping(hook_name)
232
+ # Try to infer the event type from common patterns in the hook name
233
+ case hook_name
234
+ when /pre_tool_use|pre_tool|before_tool/i
235
+ { event: "PreToolUse", matcher: nil }
236
+ when /user_prompt|prompt_submit|before_prompt/i
237
+ { event: "UserPromptSubmit", matcher: nil }
238
+ when /session_start|session_init|startup/i
239
+ { event: "SessionStart", matcher: nil }
240
+ else
241
+ # Default to PostToolUse for unknown custom hooks and post_tool patterns
242
+ { event: "PostToolUse", matcher: nil }
243
+ end
244
+ end
245
+
246
+ def display_success_message(metadata)
247
+ Aircana.human_logger.success("\nPlugin '#{metadata[:name]}' initialized successfully!")
248
+ Aircana.human_logger.info("\nPlugin structure:")
249
+ Aircana.human_logger.info(" .claude-plugin/plugin.json - Plugin metadata")
250
+ Aircana.human_logger.info(" commands/ - Slash commands")
251
+ Aircana.human_logger.info(" agents/ - Specialized agents")
252
+ Aircana.human_logger.info(" hooks/ - Event hooks")
253
+ Aircana.human_logger.info("\nNext steps:")
254
+ Aircana.human_logger.info(" - Create agents: aircana agents create")
255
+ Aircana.human_logger.info(" - Install plugin in Claude Code")
256
+ Aircana.human_logger.info(" - Run: aircana plugin info")
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tty-prompt"
5
+ require_relative "../../plugin_manifest"
6
+
7
+ module Aircana
8
+ module CLI
9
+ module Plugin # rubocop:disable Metrics/ModuleLength
10
+ class << self
11
+ def info # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
12
+ ensure_plugin_exists!
13
+
14
+ manifest = PluginManifest.new(Aircana.configuration.plugin_root)
15
+ data = manifest.read
16
+
17
+ Aircana.human_logger.info("Plugin Information:")
18
+ Aircana.human_logger.info(" Name: #{data["name"]}")
19
+ Aircana.human_logger.info(" Version: #{data["version"]}")
20
+ Aircana.human_logger.info(" Description: #{data["description"]}") if data["description"]
21
+ Aircana.human_logger.info(" Author: #{data["author"]}") if data["author"]
22
+ Aircana.human_logger.info(" License: #{data["license"]}") if data["license"]
23
+ Aircana.human_logger.info(" Homepage: #{data["homepage"]}") if data["homepage"]
24
+ Aircana.human_logger.info(" Repository: #{data["repository"]}") if data["repository"]
25
+
26
+ Aircana.human_logger.info(" Keywords: #{data["keywords"].join(", ")}") if data["keywords"]&.any?
27
+
28
+ Aircana.human_logger.info("\nManifest location: #{manifest.manifest_path}")
29
+ end
30
+
31
+ def update # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
32
+ ensure_plugin_exists!
33
+
34
+ manifest = PluginManifest.new(Aircana.configuration.plugin_root)
35
+ current_data = manifest.read
36
+
37
+ prompt = TTY::Prompt.new
38
+
39
+ # Build update hash with only fields that user wants to change
40
+ updates = {}
41
+
42
+ field_prompts = {
43
+ "description" => "Description",
44
+ "author" => "Author",
45
+ "homepage" => "Homepage URL",
46
+ "repository" => "Repository URL",
47
+ "license" => "License"
48
+ }
49
+
50
+ field_prompts.each do |field, label|
51
+ current = current_data[field]
52
+ value = prompt.ask("#{label}:", default: current)
53
+ updates[field] = value if value != current
54
+ end
55
+
56
+ # Handle keywords separately (array)
57
+ if prompt.yes?("Update keywords?")
58
+ current_keywords = (current_data["keywords"] || []).join(", ")
59
+ keywords_input = prompt.ask("Keywords (comma-separated):", default: current_keywords)
60
+ updates["keywords"] = keywords_input.split(",").map(&:strip) if keywords_input
61
+ end
62
+
63
+ if updates.empty?
64
+ Aircana.human_logger.info("No changes made.")
65
+ return
66
+ end
67
+
68
+ manifest.update(updates)
69
+ Aircana.human_logger.success("Plugin manifest updated successfully!")
70
+ end
71
+
72
+ def version(action = nil, bump_type = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
73
+ ensure_plugin_exists!
74
+
75
+ manifest = PluginManifest.new(Aircana.configuration.plugin_root)
76
+
77
+ case action
78
+ when "bump"
79
+ type = bump_type&.to_sym || :patch
80
+ new_version = manifest.bump_version(type)
81
+ Aircana.human_logger.success("Version bumped to #{new_version}")
82
+ when "set"
83
+ prompt = TTY::Prompt.new
84
+ new_version = prompt.ask("New version:")
85
+ manifest.update("version" => new_version)
86
+ Aircana.human_logger.success("Version set to #{new_version}")
87
+ else
88
+ data = manifest.read
89
+ Aircana.human_logger.info("Current version: #{data["version"]}")
90
+ end
91
+ end
92
+
93
+ def validate # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
94
+ Aircana.human_logger.info("Validating plugin structure...")
95
+
96
+ errors = []
97
+
98
+ # Check plugin manifest
99
+ manifest = PluginManifest.new(Aircana.configuration.plugin_root)
100
+ if manifest.exists?
101
+ begin
102
+ manifest.validate!
103
+ Aircana.human_logger.success("✓ Plugin manifest is valid")
104
+ rescue Aircana::Error => e
105
+ errors << "Plugin manifest validation failed: #{e.message}"
106
+ end
107
+ else
108
+ errors << "Plugin manifest not found at #{manifest.manifest_path}"
109
+ end
110
+
111
+ # Check directory structure
112
+ %w[agents commands hooks].each do |dir|
113
+ dir_path = File.join(Aircana.configuration.plugin_root, dir)
114
+ if Dir.exist?(dir_path)
115
+ Aircana.human_logger.success("✓ Directory exists: #{dir}/")
116
+ else
117
+ errors << "Missing directory: #{dir}/"
118
+ end
119
+ end
120
+
121
+ # Check hooks manifest if hooks directory exists
122
+ if Dir.exist?(Aircana.configuration.hooks_dir)
123
+ hooks_manifest = HooksManifest.new(Aircana.configuration.plugin_root)
124
+ if hooks_manifest.exists?
125
+ begin
126
+ hooks_manifest.validate!
127
+ Aircana.human_logger.success("✓ Hooks manifest is valid")
128
+ rescue Aircana::Error => e
129
+ errors << "Hooks manifest validation failed: #{e.message}"
130
+ end
131
+ end
132
+ end
133
+
134
+ # Summary
135
+ if errors.empty?
136
+ Aircana.human_logger.success("\nPlugin validation passed! ✓")
137
+ else
138
+ Aircana.human_logger.error("\nValidation failed with #{errors.size} error(s):")
139
+ errors.each { |error| Aircana.human_logger.error(" - #{error}") }
140
+ exit 1
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def ensure_plugin_exists!
147
+ manifest = PluginManifest.new(Aircana.configuration.plugin_root)
148
+ return if manifest.exists?
149
+
150
+ Aircana.human_logger.error("No plugin found in current directory.")
151
+ Aircana.human_logger.info("Run 'aircana init' to create a new plugin.")
152
+ exit 1
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -26,7 +26,7 @@ module Aircana
26
26
  "File Management" => %w[files],
27
27
  "Agent Management" => %w[agents],
28
28
  "Hook Management" => %w[hooks],
29
- "System" => %w[generate install doctor dump-context]
29
+ "System" => %w[generate init doctor dump-context]
30
30
  }
31
31
  end
32
32
 
@@ -4,27 +4,53 @@ module Aircana
4
4
  class Configuration
5
5
  attr_accessor :global_dir, :project_dir, :stream, :output_dir,
6
6
  :claude_code_config_path, :claude_code_project_config_path, :agent_knowledge_dir,
7
- :hooks_dir, :confluence_base_url, :confluence_username, :confluence_api_token
7
+ :hooks_dir, :confluence_base_url, :confluence_username, :confluence_api_token,
8
+ :plugin_root, :plugin_manifest_dir, :commands_dir, :agents_dir
8
9
 
9
10
  def initialize
10
11
  setup_directory_paths
12
+ setup_plugin_paths
11
13
  setup_claude_code_paths
12
14
  setup_stream
13
15
  setup_confluence_config
14
16
  end
15
17
 
18
+ # Returns true if the current directory is a plugin (has .claude-plugin/plugin.json)
19
+ def plugin_mode?
20
+ File.exist?(File.join(@plugin_root, ".claude-plugin", "plugin.json"))
21
+ end
22
+
23
+ # Returns the path to the plugin manifest file
24
+ def plugin_manifest_path
25
+ File.join(@plugin_manifest_dir, "plugin.json")
26
+ end
27
+
28
+ # Returns the path to the hooks manifest file
29
+ def hooks_manifest_path
30
+ File.join(@hooks_dir, "hooks.json")
31
+ end
32
+
16
33
  private
17
34
 
18
35
  def setup_directory_paths
19
36
  @global_dir = File.join(Dir.home, ".aircana")
20
37
  @project_dir = Dir.pwd
21
38
  @output_dir = File.join(@global_dir, "aircana.out")
22
- @agent_knowledge_dir = File.join(@project_dir, ".claude", "agents")
23
- @hooks_dir = File.join(@project_dir, ".aircana", "hooks")
39
+ end
40
+
41
+ def setup_plugin_paths
42
+ # Plugin root is the project directory by default
43
+ @plugin_root = @project_dir
44
+ @plugin_manifest_dir = File.join(@plugin_root, ".claude-plugin")
45
+ @commands_dir = File.join(@plugin_root, "commands")
46
+ @agents_dir = File.join(@plugin_root, "agents")
47
+ @hooks_dir = File.join(@plugin_root, "hooks")
48
+ @agent_knowledge_dir = File.join(@plugin_root, "agents")
24
49
  end
25
50
 
26
51
  def setup_claude_code_paths
27
52
  @claude_code_config_path = File.join(Dir.home, ".claude")
53
+ # For backward compatibility, keep this but plugin mode uses plugin_root
28
54
  @claude_code_project_config_path = File.join(Dir.pwd, ".claude")
29
55
  end
30
56
 
@@ -60,11 +60,12 @@ module Aircana
60
60
  end
61
61
 
62
62
  def default_output_path
63
- File.join(Aircana.configuration.claude_code_project_config_path, "agents", "#{agent_name}.md")
63
+ File.join(Aircana.configuration.agents_dir, "#{agent_name}.md")
64
64
  end
65
65
 
66
66
  def knowledge_path
67
- ".claude/agents/#{agent_name}/knowledge/"
67
+ # Use plugin-relative path for knowledge base
68
+ "agents/#{agent_name}/knowledge/"
68
69
  end
69
70
  end
70
71
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aircana
7
+ # Manages Claude Code plugin hooks manifest (hooks/hooks.json) files
8
+ class HooksManifest
9
+ VALID_EVENTS = %w[PreToolUse PostToolUse UserPromptSubmit SessionStart Notification].freeze
10
+ VALID_HOOK_TYPES = %w[command validation notification].freeze
11
+
12
+ attr_reader :plugin_root
13
+
14
+ def initialize(plugin_root)
15
+ @plugin_root = plugin_root
16
+ end
17
+
18
+ # Creates a new hooks manifest with the given hooks configuration
19
+ def create(hooks_config = {})
20
+ validate_hooks_config!(hooks_config)
21
+ write_manifest(hooks_config)
22
+
23
+ manifest_path
24
+ end
25
+
26
+ # Reads the existing hooks manifest
27
+ def read
28
+ return nil unless exists?
29
+
30
+ JSON.parse(File.read(manifest_path))
31
+ rescue JSON::ParserError => e
32
+ raise Aircana::Error, "Invalid JSON in hooks manifest: #{e.message}"
33
+ end
34
+
35
+ # Updates the hooks manifest with new values
36
+ def update(hooks_config = {})
37
+ current_data = read || {}
38
+ updated_data = deep_merge(current_data, hooks_config)
39
+
40
+ validate_hooks_config!(updated_data)
41
+ write_manifest(updated_data)
42
+
43
+ manifest_path
44
+ end
45
+
46
+ # Adds a hook to the manifest
47
+ def add_hook(event:, hook_entry:, matcher: nil)
48
+ validate_event!(event)
49
+ validate_hook_entry!(hook_entry)
50
+
51
+ current_data = read || {}
52
+ current_data[event] ||= []
53
+
54
+ hook_config = build_hook_config(hook_entry, matcher)
55
+ current_data[event] << hook_config
56
+
57
+ write_manifest(current_data)
58
+ manifest_path
59
+ end
60
+
61
+ # Removes a hook from the manifest
62
+ def remove_hook(event:, command:)
63
+ current_data = read
64
+ return manifest_path unless current_data && current_data[event]
65
+
66
+ current_data[event].reject! do |hook_group|
67
+ hook_group["hooks"]&.any? { |h| h["command"] == command }
68
+ end
69
+
70
+ current_data.delete(event) if current_data[event].empty?
71
+
72
+ write_manifest(current_data)
73
+ manifest_path
74
+ end
75
+
76
+ # Checks if the hooks manifest exists
77
+ def exists?
78
+ File.exist?(manifest_path)
79
+ end
80
+
81
+ # Returns the path to the hooks manifest
82
+ def manifest_path
83
+ File.join(plugin_root, "hooks", "hooks.json")
84
+ end
85
+
86
+ # Returns the hooks directory
87
+ def hooks_dir
88
+ File.join(plugin_root, "hooks")
89
+ end
90
+
91
+ # Validates the current manifest structure
92
+ def validate!
93
+ data = read
94
+ return true unless data # Empty manifest is valid
95
+
96
+ validate_hooks_config!(data)
97
+ true
98
+ end
99
+
100
+ # Converts old settings.local.json hook format to hooks.json format
101
+ def self.from_settings_format(settings_hooks)
102
+ hooks_config = {}
103
+
104
+ settings_hooks.each do |event, hook_groups|
105
+ hooks_config[event] = hook_groups.map do |group|
106
+ {
107
+ "hooks" => group["hooks"],
108
+ "matcher" => group["matcher"]
109
+ }.compact
110
+ end
111
+ end
112
+
113
+ hooks_config
114
+ end
115
+
116
+ private
117
+
118
+ def build_hook_config(hook_entry, matcher)
119
+ config = {
120
+ "hooks" => [hook_entry]
121
+ }
122
+ config["matcher"] = matcher if matcher
123
+ config
124
+ end
125
+
126
+ def write_manifest(data)
127
+ FileUtils.mkdir_p(hooks_dir)
128
+ File.write(manifest_path, JSON.pretty_generate(data))
129
+ end
130
+
131
+ def validate_hooks_config!(config)
132
+ return if config.nil? || config.empty?
133
+
134
+ config.each do |event, hook_groups|
135
+ validate_event!(event)
136
+
137
+ raise Aircana::Error, "Hook configuration for #{event} must be an array" unless hook_groups.is_a?(Array)
138
+
139
+ hook_groups.each do |group|
140
+ validate_hook_group!(group)
141
+ end
142
+ end
143
+ end
144
+
145
+ def validate_event!(event)
146
+ return if VALID_EVENTS.include?(event)
147
+
148
+ raise Aircana::Error, "Invalid hook event: #{event}. Must be one of: #{VALID_EVENTS.join(", ")}"
149
+ end
150
+
151
+ def validate_hook_group!(group)
152
+ raise Aircana::Error, "Hook group must be a hash with 'hooks' array" unless group.is_a?(Hash) && group["hooks"]
153
+
154
+ raise Aircana::Error, "Hook group 'hooks' must be an array" unless group["hooks"].is_a?(Array)
155
+
156
+ group["hooks"].each do |hook|
157
+ validate_hook_entry!(hook)
158
+ end
159
+ end
160
+
161
+ def validate_hook_entry!(hook)
162
+ raise Aircana::Error, "Hook entry must be a hash" unless hook.is_a?(Hash)
163
+
164
+ unless hook["type"] && VALID_HOOK_TYPES.include?(hook["type"])
165
+ raise Aircana::Error, "Hook must have a valid type: #{VALID_HOOK_TYPES.join(", ")}"
166
+ end
167
+
168
+ return if hook["command"]
169
+
170
+ raise Aircana::Error, "Hook must have a command"
171
+ end
172
+
173
+ def deep_merge(hash1, hash2)
174
+ result = hash1.dup
175
+
176
+ hash2.each do |key, value|
177
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
178
+ deep_merge(result[key], value)
179
+ elsif result[key].is_a?(Array) && value.is_a?(Array)
180
+ result[key] + value
181
+ else
182
+ value
183
+ end
184
+ end
185
+
186
+ result
187
+ end
188
+ end
189
+ end