aircana 3.0.0.rc1 → 3.0.0.rc2
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.
- checksums.yaml +4 -4
- data/.claude-plugin/plugin.json +7 -0
- data/.rspec_status +149 -152
- data/.rubocop.yml +12 -0
- data/CHANGELOG.md +24 -2
- data/CLAUDE.md +50 -19
- data/README.md +132 -55
- data/agents/apply_feedback.md +92 -0
- data/agents/executor.md +85 -0
- data/agents/jira.md +46 -0
- data/agents/planner.md +64 -0
- data/agents/reviewer.md +95 -0
- data/agents/sub-agent-coordinator.md +91 -0
- data/agents/test-agent/manifest.json +15 -0
- data/commands/air-apply-feedback.md +15 -0
- data/commands/air-ask-expert.md +42 -0
- data/commands/air-execute.md +13 -0
- data/commands/air-plan.md +33 -0
- data/commands/air-record.md +17 -0
- data/commands/air-review.md +12 -0
- data/commands/sample-command.md +1 -0
- data/hooks/hooks.json +31 -0
- data/lib/aircana/cli/app.rb +32 -4
- data/lib/aircana/cli/commands/agents.rb +41 -9
- data/lib/aircana/cli/commands/doctor_checks.rb +2 -3
- data/lib/aircana/cli/commands/hooks.rb +4 -4
- data/lib/aircana/cli/commands/init.rb +266 -0
- data/lib/aircana/cli/commands/plugin.rb +157 -0
- data/lib/aircana/cli/help_formatter.rb +1 -1
- data/lib/aircana/configuration.rb +29 -3
- data/lib/aircana/generators/agents_generator.rb +3 -2
- data/lib/aircana/hooks_manifest.rb +189 -0
- data/lib/aircana/plugin_manifest.rb +146 -0
- data/lib/aircana/system_checker.rb +0 -1
- data/lib/aircana/templates/agents/base_agent.erb +2 -2
- data/lib/aircana/templates/hooks/user_prompt_submit.erb +0 -6
- data/lib/aircana/version.rb +1 -1
- data/spec_target_1760205040_181/agents/test-agent/manifest.json +15 -0
- data/spec_target_1760205220_486/agents/test-agent/manifest.json +15 -0
- data/spec_target_1760205379_250/agents/test-agent/manifest.json +15 -0
- data/spec_target_1760205601_652/agents/test-agent/manifest.json +15 -0
- data/spec_target_1760205608_135/agents/test-agent/manifest.json +15 -0
- data/spec_target_1760205654_952/agents/test-agent/manifest.json +15 -0
- metadata +27 -2
- data/lib/aircana/cli/commands/install.rb +0 -169
@@ -0,0 +1,266 @@
|
|
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?(Aircana.configuration.output_dir)
|
159
|
+
|
160
|
+
# Copy hook scripts
|
161
|
+
hook_files = Dir.glob("#{Aircana.configuration.output_dir}/hooks/*.sh")
|
162
|
+
return unless hook_files.any?
|
163
|
+
|
164
|
+
hook_files.each do |file|
|
165
|
+
Aircana.human_logger.success("Installing hook: #{File.basename(file)}")
|
166
|
+
FileUtils.cp(file, hooks_dir)
|
167
|
+
end
|
168
|
+
|
169
|
+
# Create hooks manifest
|
170
|
+
create_hooks_manifest
|
171
|
+
end
|
172
|
+
|
173
|
+
def create_hooks_manifest
|
174
|
+
hooks_config = build_hooks_config
|
175
|
+
|
176
|
+
return if hooks_config.empty?
|
177
|
+
|
178
|
+
manifest = HooksManifest.new(Aircana.configuration.plugin_root)
|
179
|
+
manifest.create(hooks_config)
|
180
|
+
|
181
|
+
Aircana.human_logger.success("Created hooks manifest at hooks/hooks.json")
|
182
|
+
end
|
183
|
+
|
184
|
+
def build_hooks_config
|
185
|
+
hooks = {}
|
186
|
+
|
187
|
+
# Map hook files to Claude Code hook events and their properties
|
188
|
+
hook_mappings = {
|
189
|
+
"pre_tool_use" => { event: "PreToolUse", matcher: nil },
|
190
|
+
"post_tool_use" => { event: "PostToolUse", matcher: nil },
|
191
|
+
"user_prompt_submit" => { event: "UserPromptSubmit", matcher: nil },
|
192
|
+
"session_start" => { event: "SessionStart", matcher: nil },
|
193
|
+
"notification_sqs" => { event: "Notification", matcher: nil },
|
194
|
+
"rubocop_pre_commit" => { event: "PreToolUse", matcher: "Bash" },
|
195
|
+
"rspec_test" => { event: "PostToolUse", matcher: "Bash" },
|
196
|
+
"bundle_install" => { event: "PostToolUse", matcher: "Bash" }
|
197
|
+
}
|
198
|
+
|
199
|
+
Dir.glob("#{Aircana.configuration.hooks_dir}/*.sh").each do |hook_file|
|
200
|
+
hook_name = File.basename(hook_file, ".sh")
|
201
|
+
|
202
|
+
# Determine mapping for this hook
|
203
|
+
mapping = if hook_mappings.key?(hook_name)
|
204
|
+
hook_mappings[hook_name]
|
205
|
+
else
|
206
|
+
# For custom hooks, try to infer the event type from the filename
|
207
|
+
infer_hook_mapping(hook_name)
|
208
|
+
end
|
209
|
+
|
210
|
+
next unless mapping
|
211
|
+
|
212
|
+
event_key = mapping[:event]
|
213
|
+
|
214
|
+
# Create relative path using ${CLAUDE_PLUGIN_ROOT}
|
215
|
+
relative_path = "${CLAUDE_PLUGIN_ROOT}/hooks/#{hook_name}.sh"
|
216
|
+
|
217
|
+
hook_entry = {
|
218
|
+
"type" => "command",
|
219
|
+
"command" => relative_path
|
220
|
+
}
|
221
|
+
|
222
|
+
hook_config = {
|
223
|
+
"hooks" => [hook_entry]
|
224
|
+
}
|
225
|
+
|
226
|
+
# Add matcher if specified
|
227
|
+
hook_config["matcher"] = mapping[:matcher] if mapping[:matcher]
|
228
|
+
|
229
|
+
hooks[event_key] ||= []
|
230
|
+
hooks[event_key] << hook_config
|
231
|
+
end
|
232
|
+
|
233
|
+
hooks
|
234
|
+
end
|
235
|
+
|
236
|
+
def infer_hook_mapping(hook_name)
|
237
|
+
# Try to infer the event type from common patterns in the hook name
|
238
|
+
case hook_name
|
239
|
+
when /pre_tool_use|pre_tool|before_tool/i
|
240
|
+
{ event: "PreToolUse", matcher: nil }
|
241
|
+
when /user_prompt|prompt_submit|before_prompt/i
|
242
|
+
{ event: "UserPromptSubmit", matcher: nil }
|
243
|
+
when /session_start|session_init|startup/i
|
244
|
+
{ event: "SessionStart", matcher: nil }
|
245
|
+
else
|
246
|
+
# Default to PostToolUse for unknown custom hooks and post_tool patterns
|
247
|
+
{ event: "PostToolUse", matcher: nil }
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
def display_success_message(metadata)
|
252
|
+
Aircana.human_logger.success("\nPlugin '#{metadata[:name]}' initialized successfully!")
|
253
|
+
Aircana.human_logger.info("\nPlugin structure:")
|
254
|
+
Aircana.human_logger.info(" .claude-plugin/plugin.json - Plugin metadata")
|
255
|
+
Aircana.human_logger.info(" commands/ - Slash commands")
|
256
|
+
Aircana.human_logger.info(" agents/ - Specialized agents")
|
257
|
+
Aircana.human_logger.info(" hooks/ - Event hooks")
|
258
|
+
Aircana.human_logger.info("\nNext steps:")
|
259
|
+
Aircana.human_logger.info(" - Create agents: aircana agents create")
|
260
|
+
Aircana.human_logger.info(" - Install plugin in Claude Code")
|
261
|
+
Aircana.human_logger.info(" - Run: aircana plugin info")
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
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
|
@@ -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
|
-
|
23
|
-
|
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.
|
63
|
+
File.join(Aircana.configuration.agents_dir, "#{agent_name}.md")
|
64
64
|
end
|
65
65
|
|
66
66
|
def knowledge_path
|
67
|
-
|
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
|