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.
- checksums.yaml +4 -4
- data/.rspec_status +164 -104
- data/.rubocop.yml +34 -0
- data/CLAUDE.md +54 -9
- data/README.md +31 -19
- data/lib/aircana/cli/app.rb +93 -26
- data/lib/aircana/cli/commands/agents.rb +75 -4
- data/lib/aircana/cli/commands/files.rb +65 -0
- data/lib/aircana/cli/commands/generate.rb +19 -0
- data/lib/aircana/cli/commands/hooks.rb +276 -0
- data/lib/aircana/cli/commands/install.rb +113 -0
- data/lib/aircana/cli/commands/project.rb +156 -0
- data/lib/aircana/cli/help_formatter.rb +90 -0
- data/lib/aircana/cli/subcommand.rb +2 -1
- data/lib/aircana/configuration.rb +2 -1
- data/lib/aircana/contexts/confluence.rb +76 -3
- data/lib/aircana/contexts/manifest.rb +148 -0
- data/lib/aircana/generators/agents_generator.rb +1 -1
- data/lib/aircana/generators/hooks_generator.rb +82 -0
- data/lib/aircana/generators/project_config_generator.rb +54 -0
- data/lib/aircana/symlink_manager.rb +158 -0
- data/lib/aircana/system_checker.rb +10 -0
- data/lib/aircana/templates/hooks/bundle_install.erb +63 -0
- data/lib/aircana/templates/hooks/post_tool_use.erb +49 -0
- data/lib/aircana/templates/hooks/pre_tool_use.erb +43 -0
- data/lib/aircana/templates/hooks/rspec_test.erb +72 -0
- data/lib/aircana/templates/hooks/rubocop_pre_commit.erb +55 -0
- data/lib/aircana/templates/hooks/session_start.erb +127 -0
- data/lib/aircana/templates/hooks/user_prompt_submit.erb +46 -0
- data/lib/aircana/version.rb +1 -1
- metadata +19 -5
data/lib/aircana/cli/app.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
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
|
-
|
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
|