aircana 0.1.0 → 1.0.0
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 +146 -101
- data/.rubocop.yml +29 -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 +43 -0
- 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/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 +138 -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 +18 -5
@@ -1,6 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "json"
|
3
4
|
require_relative "generate"
|
5
|
+
require_relative "../../generators/project_config_generator"
|
4
6
|
|
5
7
|
module Aircana
|
6
8
|
module CLI
|
@@ -8,7 +10,9 @@ module Aircana
|
|
8
10
|
class << self
|
9
11
|
def run
|
10
12
|
ensure_output_exists
|
13
|
+
ensure_project_config_exists
|
11
14
|
install_commands_to_claude
|
15
|
+
install_hooks_to_claude
|
12
16
|
end
|
13
17
|
|
14
18
|
private
|
@@ -20,6 +24,14 @@ module Aircana
|
|
20
24
|
Generate.run
|
21
25
|
end
|
22
26
|
|
27
|
+
def ensure_project_config_exists
|
28
|
+
project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
29
|
+
return if File.exist?(project_json_path)
|
30
|
+
|
31
|
+
Aircana.human_logger.info("Creating project.json for multi-root support...")
|
32
|
+
Aircana::Generators::ProjectConfigGenerator.new.generate
|
33
|
+
end
|
34
|
+
|
23
35
|
def install_commands_to_claude
|
24
36
|
claude_commands_dir = File.join(Aircana.configuration.claude_code_project_config_path, "commands")
|
25
37
|
Aircana.create_dir_if_needed(claude_commands_dir)
|
@@ -61,6 +73,107 @@ module Aircana
|
|
61
73
|
require_relative "../../generators/agents_generator"
|
62
74
|
Aircana::Generators::AgentsGenerator.available_default_agents.include?(agent_name)
|
63
75
|
end
|
76
|
+
|
77
|
+
def install_hooks_to_claude
|
78
|
+
return unless Dir.exist?(Aircana.configuration.hooks_dir)
|
79
|
+
|
80
|
+
settings_file = File.join(Aircana.configuration.claude_code_project_config_path, "settings.local.json")
|
81
|
+
install_hooks_to_settings(settings_file)
|
82
|
+
end
|
83
|
+
|
84
|
+
def install_hooks_to_settings(settings_file)
|
85
|
+
settings = load_settings(settings_file)
|
86
|
+
hook_configs = build_hook_configs
|
87
|
+
|
88
|
+
return if hook_configs.empty?
|
89
|
+
|
90
|
+
settings["hooks"] = hook_configs
|
91
|
+
save_settings(settings_file, settings)
|
92
|
+
|
93
|
+
Aircana.human_logger.success("Installed hooks to #{settings_file}")
|
94
|
+
end
|
95
|
+
|
96
|
+
def load_settings(settings_file)
|
97
|
+
if File.exist?(settings_file)
|
98
|
+
JSON.parse(File.read(settings_file))
|
99
|
+
else
|
100
|
+
Aircana.create_dir_if_needed(File.dirname(settings_file))
|
101
|
+
{}
|
102
|
+
end
|
103
|
+
rescue JSON::ParserError
|
104
|
+
Aircana.human_logger.warn("Invalid JSON in #{settings_file}, creating new settings")
|
105
|
+
{}
|
106
|
+
end
|
107
|
+
|
108
|
+
def save_settings(settings_file, settings)
|
109
|
+
File.write(settings_file, JSON.pretty_generate(settings))
|
110
|
+
end
|
111
|
+
|
112
|
+
def build_hook_configs
|
113
|
+
hooks = {}
|
114
|
+
|
115
|
+
# Map hook files to Claude Code hook events and their properties
|
116
|
+
hook_mappings = {
|
117
|
+
"pre_tool_use" => { event: "PreToolUse", matcher: nil },
|
118
|
+
"post_tool_use" => { event: "PostToolUse", matcher: nil },
|
119
|
+
"user_prompt_submit" => { event: "UserPromptSubmit", matcher: nil },
|
120
|
+
"session_start" => { event: "SessionStart", matcher: nil },
|
121
|
+
"rubocop_pre_commit" => { event: "PreToolUse", matcher: "Bash" },
|
122
|
+
"rspec_test" => { event: "PostToolUse", matcher: "Bash" },
|
123
|
+
"bundle_install" => { event: "PostToolUse", matcher: "Bash" }
|
124
|
+
}
|
125
|
+
|
126
|
+
Dir.glob("#{Aircana.configuration.hooks_dir}/*.sh").each do |hook_file|
|
127
|
+
hook_name = File.basename(hook_file, ".sh")
|
128
|
+
|
129
|
+
# Determine mapping for this hook
|
130
|
+
mapping = if hook_mappings.key?(hook_name)
|
131
|
+
hook_mappings[hook_name]
|
132
|
+
else
|
133
|
+
# For custom hooks, try to infer the event type from the filename
|
134
|
+
infer_hook_mapping(hook_name)
|
135
|
+
end
|
136
|
+
|
137
|
+
next unless mapping
|
138
|
+
|
139
|
+
event_key = mapping[:event]
|
140
|
+
|
141
|
+
# Create relative path from project root
|
142
|
+
relative_path = File.join(".aircana", "hooks", "#{hook_name}.sh")
|
143
|
+
|
144
|
+
hook_entry = {
|
145
|
+
"hooks" => [
|
146
|
+
{
|
147
|
+
"type" => "command",
|
148
|
+
"command" => relative_path
|
149
|
+
}
|
150
|
+
]
|
151
|
+
}
|
152
|
+
|
153
|
+
# Add matcher if specified
|
154
|
+
hook_entry["matcher"] = mapping[:matcher] if mapping[:matcher]
|
155
|
+
|
156
|
+
hooks[event_key] ||= []
|
157
|
+
hooks[event_key] << hook_entry
|
158
|
+
end
|
159
|
+
|
160
|
+
hooks
|
161
|
+
end
|
162
|
+
|
163
|
+
def infer_hook_mapping(hook_name)
|
164
|
+
# Try to infer the event type from common patterns in the hook name
|
165
|
+
case hook_name
|
166
|
+
when /pre_tool_use|pre_tool|before_tool/i
|
167
|
+
{ event: "PreToolUse", matcher: nil }
|
168
|
+
when /user_prompt|prompt_submit|before_prompt/i
|
169
|
+
{ event: "UserPromptSubmit", matcher: nil }
|
170
|
+
when /session_start|session_init|startup/i
|
171
|
+
{ event: "SessionStart", matcher: nil }
|
172
|
+
else
|
173
|
+
# Default to PostToolUse for unknown custom hooks and post_tool patterns
|
174
|
+
{ event: "PostToolUse", matcher: nil }
|
175
|
+
end
|
176
|
+
end
|
64
177
|
end
|
65
178
|
end
|
66
179
|
end
|
@@ -0,0 +1,156 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "tty-prompt"
|
5
|
+
require_relative "../../symlink_manager"
|
6
|
+
require_relative "../../generators/project_config_generator"
|
7
|
+
|
8
|
+
module Aircana
|
9
|
+
module CLI
|
10
|
+
module Project
|
11
|
+
class << self
|
12
|
+
def init
|
13
|
+
generator = Aircana::Generators::ProjectConfigGenerator.new
|
14
|
+
config_path = generator.generate
|
15
|
+
|
16
|
+
Aircana.human_logger.success "Initialized project.json at #{config_path}"
|
17
|
+
Aircana.human_logger.info "Add folders using: aircana project add <path>"
|
18
|
+
end
|
19
|
+
|
20
|
+
def add(folder_path)
|
21
|
+
project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
22
|
+
|
23
|
+
# Create project.json if it doesn't exist
|
24
|
+
init unless File.exist?(project_json_path)
|
25
|
+
|
26
|
+
# Validate folder exists
|
27
|
+
full_path = File.join(Aircana.configuration.project_dir, folder_path)
|
28
|
+
unless Dir.exist?(full_path)
|
29
|
+
Aircana.human_logger.error "Folder not found: #{folder_path}"
|
30
|
+
return
|
31
|
+
end
|
32
|
+
|
33
|
+
# Load existing config
|
34
|
+
config = JSON.parse(File.read(project_json_path))
|
35
|
+
config["folders"] ||= []
|
36
|
+
|
37
|
+
# Check if folder already exists
|
38
|
+
if config["folders"].any? { |f| f["path"] == folder_path }
|
39
|
+
Aircana.human_logger.warn "Folder already configured: #{folder_path}"
|
40
|
+
return
|
41
|
+
end
|
42
|
+
|
43
|
+
# Add the folder
|
44
|
+
config["folders"] << { "path" => folder_path }
|
45
|
+
|
46
|
+
# Save updated config
|
47
|
+
File.write(project_json_path, JSON.pretty_generate(config))
|
48
|
+
|
49
|
+
Aircana.human_logger.success "Added folder: #{folder_path}"
|
50
|
+
|
51
|
+
# Check what agents/knowledge would be available
|
52
|
+
check_folder_contents(folder_path)
|
53
|
+
|
54
|
+
# Offer to sync
|
55
|
+
prompt = TTY::Prompt.new
|
56
|
+
sync if prompt.yes?("Would you like to sync symlinks now?")
|
57
|
+
end
|
58
|
+
|
59
|
+
def remove(folder_path)
|
60
|
+
project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
61
|
+
|
62
|
+
unless File.exist?(project_json_path)
|
63
|
+
Aircana.human_logger.error "No project.json found. Run 'aircana project init' first."
|
64
|
+
return
|
65
|
+
end
|
66
|
+
|
67
|
+
# Load existing config
|
68
|
+
config = JSON.parse(File.read(project_json_path))
|
69
|
+
config["folders"] ||= []
|
70
|
+
|
71
|
+
# Remove the folder
|
72
|
+
original_count = config["folders"].size
|
73
|
+
config["folders"].reject! { |f| f["path"] == folder_path }
|
74
|
+
|
75
|
+
if config["folders"].size == original_count
|
76
|
+
Aircana.human_logger.warn "Folder not found in configuration: #{folder_path}"
|
77
|
+
return
|
78
|
+
end
|
79
|
+
|
80
|
+
# Save updated config
|
81
|
+
File.write(project_json_path, JSON.pretty_generate(config))
|
82
|
+
|
83
|
+
Aircana.human_logger.success "Removed folder: #{folder_path}"
|
84
|
+
|
85
|
+
# Clean up symlinks
|
86
|
+
Aircana::SymlinkManager.cleanup_broken_symlinks
|
87
|
+
end
|
88
|
+
|
89
|
+
def list
|
90
|
+
project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
91
|
+
|
92
|
+
unless File.exist?(project_json_path)
|
93
|
+
Aircana.human_logger.info "No project.json found. Run 'aircana project init' to create one."
|
94
|
+
return
|
95
|
+
end
|
96
|
+
|
97
|
+
config = JSON.parse(File.read(project_json_path))
|
98
|
+
folders = config["folders"] || []
|
99
|
+
|
100
|
+
if folders.empty?
|
101
|
+
Aircana.human_logger.info "No folders configured."
|
102
|
+
Aircana.human_logger.info "Add folders using: aircana project add <path>"
|
103
|
+
return
|
104
|
+
end
|
105
|
+
|
106
|
+
Aircana.human_logger.info "Configured folders:"
|
107
|
+
folders.each do |folder|
|
108
|
+
folder_path = folder["path"]
|
109
|
+
status = Dir.exist?(File.join(Aircana.configuration.project_dir, folder_path)) ? "✓" : "✗"
|
110
|
+
Aircana.human_logger.info " #{status} #{folder_path}"
|
111
|
+
|
112
|
+
# Show available agents if folder exists
|
113
|
+
check_folder_contents(folder_path, indent: " ") if status == "✓"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def sync
|
118
|
+
Aircana.human_logger.info "Syncing multi-root project symlinks..."
|
119
|
+
|
120
|
+
stats = Aircana::SymlinkManager.sync_multi_root_agents
|
121
|
+
|
122
|
+
if stats[:agents].zero? && stats[:knowledge].zero?
|
123
|
+
Aircana.human_logger.info "No agents or knowledge bases to link."
|
124
|
+
else
|
125
|
+
Aircana.human_logger.success "Sync complete: #{stats[:agents]} agents, #{stats[:knowledge]} knowledge bases"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def check_folder_contents(folder_path, indent: " ")
|
132
|
+
agents_dir = File.join(folder_path, ".claude", "agents")
|
133
|
+
knowledge_dir = File.join(folder_path, ".aircana", "agents")
|
134
|
+
|
135
|
+
agents = []
|
136
|
+
knowledge = []
|
137
|
+
|
138
|
+
agents = Dir.glob("#{agents_dir}/*.md").map { |f| File.basename(f, ".md") } if Dir.exist?(agents_dir)
|
139
|
+
|
140
|
+
if Dir.exist?(knowledge_dir)
|
141
|
+
knowledge = Dir.glob("#{knowledge_dir}/*").select { |d| File.directory?(d) }
|
142
|
+
.map { |d| File.basename(d) }
|
143
|
+
end
|
144
|
+
|
145
|
+
Aircana.human_logger.info "#{indent}Agents: #{agents.join(", ")}" if agents.any?
|
146
|
+
|
147
|
+
Aircana.human_logger.info "#{indent}Knowledge: #{knowledge.join(", ")}" if knowledge.any?
|
148
|
+
|
149
|
+
return unless agents.empty? && knowledge.empty?
|
150
|
+
|
151
|
+
Aircana.human_logger.info "#{indent}No agents or knowledge found"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aircana
|
4
|
+
module CLI
|
5
|
+
# Custom help formatter to organize commands into logical groups
|
6
|
+
module HelpFormatter
|
7
|
+
def help(command = nil, subcommand: false)
|
8
|
+
if command
|
9
|
+
super
|
10
|
+
else
|
11
|
+
print_grouped_commands
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def print_grouped_commands
|
18
|
+
say "Aircana - Context Management for Claude Code", :bold
|
19
|
+
command_groups.each { |group_name, commands| print_command_group(group_name, commands) }
|
20
|
+
say
|
21
|
+
say "Use 'aircana help [COMMAND]' for more information on a specific command.", :green
|
22
|
+
end
|
23
|
+
|
24
|
+
def command_groups
|
25
|
+
{
|
26
|
+
"File Management" => %w[files],
|
27
|
+
"Agent Management" => %w[agents],
|
28
|
+
"Hook Management" => %w[hooks],
|
29
|
+
"Project Management" => %w[project],
|
30
|
+
"System" => %w[generate install doctor dump-context]
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
def print_command_group(group_name, group_commands)
|
35
|
+
print_group_header(group_name)
|
36
|
+
group_commands.each { |cmd_name| print_group_command(cmd_name) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def print_group_header(group_name)
|
40
|
+
say
|
41
|
+
say "#{group_name}:", :yellow
|
42
|
+
end
|
43
|
+
|
44
|
+
def print_group_command(cmd_name)
|
45
|
+
cmd = self.class.commands[cmd_name]
|
46
|
+
return unless cmd
|
47
|
+
|
48
|
+
if subcommand?(cmd_name)
|
49
|
+
print_subcommand_group(cmd_name, cmd)
|
50
|
+
else
|
51
|
+
print_command(cmd)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def subcommand?(cmd_name)
|
56
|
+
%w[files agents hooks project].include?(cmd_name)
|
57
|
+
end
|
58
|
+
|
59
|
+
def print_subcommand_group(subcommand_name, cmd)
|
60
|
+
subcommand_class = get_subcommand_class(subcommand_name)
|
61
|
+
return print_command(cmd) unless subcommand_class
|
62
|
+
|
63
|
+
print_subcommands(subcommand_class, subcommand_name)
|
64
|
+
end
|
65
|
+
|
66
|
+
def get_subcommand_class(subcommand_name)
|
67
|
+
class_name = "#{subcommand_name.capitalize}Subcommand"
|
68
|
+
return self.class.const_get(class_name) if self.class.const_defined?(class_name)
|
69
|
+
|
70
|
+
nil
|
71
|
+
rescue NameError
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def print_subcommands(subcommand_class, subcommand_name)
|
76
|
+
subcommand_class.commands.each_value do |sub_cmd|
|
77
|
+
usage = "aircana #{subcommand_name} #{sub_cmd.usage}"
|
78
|
+
desc = sub_cmd.description
|
79
|
+
say " #{usage.ljust(35)} # #{desc}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def print_command(cmd)
|
84
|
+
usage = "aircana #{cmd.usage}"
|
85
|
+
desc = cmd.description
|
86
|
+
say " #{usage.ljust(35)} # #{desc}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -10,7 +10,8 @@ module Aircana
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def self.subcommand_prefix
|
13
|
-
name.
|
13
|
+
# Extract the main command name from class name (e.g., "FilesSubcommand" -> "files")
|
14
|
+
name.gsub(/.*::/, "").gsub(/Subcommand$/, "").gsub(/^[A-Z]/) do |match|
|
14
15
|
match[0].downcase
|
15
16
|
end.gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" } # rubocop:disable Style/MultilineBlockChain
|
16
17
|
end
|
@@ -4,7 +4,7 @@ module Aircana
|
|
4
4
|
class Configuration
|
5
5
|
attr_accessor :global_dir, :project_dir, :relevant_project_files_dir, :stream, :output_dir,
|
6
6
|
:claude_code_config_path, :claude_code_project_config_path, :agent_knowledge_dir,
|
7
|
-
:confluence_base_url, :confluence_username, :confluence_api_token
|
7
|
+
:hooks_dir, :confluence_base_url, :confluence_username, :confluence_api_token
|
8
8
|
|
9
9
|
def initialize
|
10
10
|
setup_directory_paths
|
@@ -21,6 +21,7 @@ module Aircana
|
|
21
21
|
@relevant_project_files_dir = File.join(@project_dir, ".aircana", "relevant_files")
|
22
22
|
@output_dir = File.join(@global_dir, "aircana.out")
|
23
23
|
@agent_knowledge_dir = File.join(@project_dir, ".aircana", "agents")
|
24
|
+
@hooks_dir = File.join(@project_dir, ".aircana", "hooks")
|
24
25
|
end
|
25
26
|
|
26
27
|
def setup_claude_code_paths
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_generator"
|
4
|
+
|
5
|
+
module Aircana
|
6
|
+
module Generators
|
7
|
+
class HooksGenerator < BaseGenerator
|
8
|
+
# All available hook types (for manual creation)
|
9
|
+
ALL_HOOK_TYPES = %w[
|
10
|
+
pre_tool_use
|
11
|
+
post_tool_use
|
12
|
+
user_prompt_submit
|
13
|
+
session_start
|
14
|
+
rubocop_pre_commit
|
15
|
+
rspec_test
|
16
|
+
bundle_install
|
17
|
+
].freeze
|
18
|
+
|
19
|
+
# Default hooks that are auto-installed
|
20
|
+
DEFAULT_HOOK_TYPES = %w[
|
21
|
+
session_start
|
22
|
+
].freeze
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def available_default_hooks
|
26
|
+
DEFAULT_HOOK_TYPES
|
27
|
+
end
|
28
|
+
|
29
|
+
def all_available_hooks
|
30
|
+
ALL_HOOK_TYPES
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_default_hook(hook_name)
|
34
|
+
return unless all_available_hooks.include?(hook_name)
|
35
|
+
|
36
|
+
template_path = File.join(File.dirname(__FILE__), "..", "templates", "hooks", "#{hook_name}.erb")
|
37
|
+
output_path = File.join(Aircana.configuration.hooks_dir, "#{hook_name}.sh")
|
38
|
+
|
39
|
+
generator = new(file_in: template_path, file_out: output_path)
|
40
|
+
generator.generate
|
41
|
+
end
|
42
|
+
|
43
|
+
def create_all_default_hooks
|
44
|
+
available_default_hooks.each { |hook_name| create_default_hook(hook_name) }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize(hook_name: nil, **)
|
49
|
+
@hook_name = hook_name
|
50
|
+
|
51
|
+
if hook_name
|
52
|
+
template_path = File.join(File.dirname(__FILE__), "..", "templates", "hooks", "#{hook_name}.erb")
|
53
|
+
output_path = File.join(Aircana.configuration.hooks_dir, "#{hook_name}.sh")
|
54
|
+
super(file_in: template_path, file_out: output_path)
|
55
|
+
else
|
56
|
+
super(**)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate
|
61
|
+
result = super
|
62
|
+
make_executable if File.exist?(file_out)
|
63
|
+
result
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
def locals
|
69
|
+
super.merge(
|
70
|
+
hook_name: @hook_name,
|
71
|
+
project_root: Dir.pwd
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def make_executable
|
78
|
+
File.chmod(0o755, file_out)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_generator"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Aircana
|
7
|
+
module Generators
|
8
|
+
class ProjectConfigGenerator < BaseGenerator
|
9
|
+
def initialize(file_in: nil, file_out: nil)
|
10
|
+
super(
|
11
|
+
file_in: file_in || default_template_path,
|
12
|
+
file_out: file_out || default_output_path
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate
|
17
|
+
# Create the project.json with default content
|
18
|
+
project_config = default_project_config
|
19
|
+
|
20
|
+
Aircana.create_dir_if_needed(File.dirname(file_out))
|
21
|
+
File.write(file_out, JSON.pretty_generate(project_config))
|
22
|
+
|
23
|
+
Aircana.human_logger.success "Generated project.json at #{file_out}"
|
24
|
+
file_out
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def default_template_path
|
30
|
+
# We don't use a template for this, generate directly
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def default_output_path
|
35
|
+
File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
36
|
+
end
|
37
|
+
|
38
|
+
def default_project_config
|
39
|
+
{
|
40
|
+
"folders" => [],
|
41
|
+
"_comment" => [
|
42
|
+
"Add folders to include agents from sub-projects",
|
43
|
+
"Example:",
|
44
|
+
" 'folders': [",
|
45
|
+
" { 'path': 'frontend' },",
|
46
|
+
" { 'path': 'backend' },",
|
47
|
+
" { 'path': 'shared/utils' }",
|
48
|
+
" ]"
|
49
|
+
]
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module Aircana
|
7
|
+
class SymlinkManager
|
8
|
+
class << self
|
9
|
+
def sync_multi_root_agents
|
10
|
+
project_json_path = File.join(Aircana.configuration.project_dir, ".aircana", "project.json")
|
11
|
+
|
12
|
+
unless File.exist?(project_json_path)
|
13
|
+
Aircana.human_logger.info "No project.json found, skipping multi-root sync"
|
14
|
+
return { agents: 0, knowledge: 0 }
|
15
|
+
end
|
16
|
+
|
17
|
+
begin
|
18
|
+
config = JSON.parse(File.read(project_json_path))
|
19
|
+
folders = config["folders"] || []
|
20
|
+
|
21
|
+
if folders.empty?
|
22
|
+
Aircana.human_logger.info "No folders configured in project.json"
|
23
|
+
return { agents: 0, knowledge: 0 }
|
24
|
+
end
|
25
|
+
|
26
|
+
cleanup_broken_symlinks
|
27
|
+
create_symlinks_for_folders(folders)
|
28
|
+
rescue JSON::ParserError => e
|
29
|
+
Aircana.human_logger.error "Invalid JSON in project.json: #{e.message}"
|
30
|
+
{ agents: 0, knowledge: 0 }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def cleanup_broken_symlinks
|
35
|
+
claude_agents_dir = File.join(Aircana.configuration.project_dir, ".claude", "agents")
|
36
|
+
aircana_agents_dir = File.join(Aircana.configuration.project_dir, ".aircana", "agents")
|
37
|
+
|
38
|
+
[claude_agents_dir, aircana_agents_dir].each do |dir|
|
39
|
+
next unless Dir.exist?(dir)
|
40
|
+
|
41
|
+
Dir.glob("#{dir}/*").each do |path|
|
42
|
+
if File.symlink?(path) && !File.exist?(path)
|
43
|
+
File.delete(path)
|
44
|
+
Aircana.human_logger.info "Removed broken symlink: #{path}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def create_symlinks_for_folders(folders)
|
51
|
+
stats = { agents: 0, knowledge: 0 }
|
52
|
+
|
53
|
+
folders.each do |folder_config|
|
54
|
+
folder_path = folder_config["path"]
|
55
|
+
next unless folder_path_valid?(folder_path)
|
56
|
+
|
57
|
+
prefix = folder_path.tr("/", "_")
|
58
|
+
|
59
|
+
stats[:agents] += link_agents(folder_path, prefix)
|
60
|
+
stats[:knowledge] += link_knowledge(folder_path, prefix)
|
61
|
+
end
|
62
|
+
|
63
|
+
Aircana.human_logger.success "Linked #{stats[:agents]} agents and #{stats[:knowledge]} knowledge bases"
|
64
|
+
stats
|
65
|
+
end
|
66
|
+
|
67
|
+
def link_agents(folder_path, prefix)
|
68
|
+
source_dir = File.join(folder_path, ".claude", "agents")
|
69
|
+
target_dir = File.join(Aircana.configuration.project_dir, ".claude", "agents")
|
70
|
+
|
71
|
+
return 0 unless Dir.exist?(source_dir)
|
72
|
+
|
73
|
+
FileUtils.mkdir_p(target_dir)
|
74
|
+
linked = 0
|
75
|
+
|
76
|
+
Dir.glob("#{source_dir}/*.md").each do |agent_file|
|
77
|
+
agent_name = File.basename(agent_file, ".md")
|
78
|
+
link_name = "#{prefix}_#{agent_name}.md"
|
79
|
+
target_path = File.join(target_dir, link_name)
|
80
|
+
|
81
|
+
# Use relative paths for symlinks
|
82
|
+
relative_path = calculate_relative_path(target_dir, agent_file)
|
83
|
+
|
84
|
+
File.symlink(relative_path, target_path) unless File.exist?(target_path)
|
85
|
+
linked += 1
|
86
|
+
Aircana.human_logger.info "Linked agent: #{link_name}"
|
87
|
+
end
|
88
|
+
|
89
|
+
linked
|
90
|
+
end
|
91
|
+
|
92
|
+
def link_knowledge(folder_path, prefix)
|
93
|
+
source_dir = File.join(folder_path, ".aircana", "agents")
|
94
|
+
target_dir = File.join(Aircana.configuration.project_dir, ".aircana", "agents")
|
95
|
+
|
96
|
+
return 0 unless Dir.exist?(source_dir)
|
97
|
+
|
98
|
+
FileUtils.mkdir_p(target_dir)
|
99
|
+
linked = 0
|
100
|
+
|
101
|
+
Dir.glob("#{source_dir}/*").each do |agent_dir|
|
102
|
+
next unless File.directory?(agent_dir)
|
103
|
+
|
104
|
+
agent_name = File.basename(agent_dir)
|
105
|
+
link_name = "#{prefix}_#{agent_name}"
|
106
|
+
target_path = File.join(target_dir, link_name)
|
107
|
+
|
108
|
+
# Use relative paths for symlinks
|
109
|
+
relative_path = calculate_relative_path(target_dir, agent_dir)
|
110
|
+
|
111
|
+
File.symlink(relative_path, target_path) unless File.exist?(target_path)
|
112
|
+
linked += 1
|
113
|
+
Aircana.human_logger.info "Linked knowledge: #{link_name}"
|
114
|
+
end
|
115
|
+
|
116
|
+
linked
|
117
|
+
end
|
118
|
+
|
119
|
+
def folder_path_valid?(folder_path)
|
120
|
+
full_path = File.join(Aircana.configuration.project_dir, folder_path)
|
121
|
+
|
122
|
+
unless Dir.exist?(full_path)
|
123
|
+
Aircana.human_logger.warn "Folder not found: #{folder_path}"
|
124
|
+
return false
|
125
|
+
end
|
126
|
+
|
127
|
+
true
|
128
|
+
end
|
129
|
+
|
130
|
+
def calculate_relative_path(from_dir, to_path)
|
131
|
+
# Calculate the relative path from one directory to another
|
132
|
+
from = Pathname.new(File.expand_path(from_dir))
|
133
|
+
to = Pathname.new(File.expand_path(to_path))
|
134
|
+
to.relative_path_from(from).to_s
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|