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.
@@ -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,158 @@
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
+
137
+ # Helper methods for resolving symlinked agent paths
138
+ def resolve_agent_path(agent_name)
139
+ agent_path = File.join(Aircana.configuration.agent_knowledge_dir, agent_name)
140
+
141
+ if File.symlink?(agent_path)
142
+ File.readlink(agent_path)
143
+ else
144
+ agent_path
145
+ end
146
+ end
147
+
148
+ def agent_is_symlinked?(agent_name)
149
+ agent_path = File.join(Aircana.configuration.agent_knowledge_dir, agent_name)
150
+ File.symlink?(agent_path)
151
+ end
152
+
153
+ def resolve_symlinked_path(path)
154
+ File.symlink?(path) ? File.readlink(path) : path
155
+ end
156
+ end
157
+ end
158
+ end
@@ -23,6 +23,16 @@ module Aircana
23
23
  "Arch" => "pacman -S git",
24
24
  "Other" => "https://git-scm.com/downloads"
25
25
  }
26
+ },
27
+ "jq" => {
28
+ purpose: "JSON parsing for multi-root configuration",
29
+ install: {
30
+ "macOS" => "brew install jq",
31
+ "Ubuntu/Debian" => "apt install jq",
32
+ "Fedora/CentOS" => "dnf install jq",
33
+ "Arch" => "pacman -S jq",
34
+ "Other" => "https://jqlang.github.io/jq/download/"
35
+ }
26
36
  }
27
37
  }.freeze
28
38
 
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # Bundle install hook generated by Aircana
3
+ # This hook runs bundle install when Gemfile changes are detected
4
+
5
+ # Get the tool details
6
+ TOOL_NAME="$1"
7
+ TOOL_PARAMS="$2"
8
+
9
+ # Only act on file modification tools
10
+ case "$TOOL_NAME" in
11
+ "Edit"|"Write"|"MultiEdit")
12
+ ;;
13
+ *)
14
+ exit 0
15
+ ;;
16
+ esac
17
+
18
+ # Check if Gemfile was modified
19
+ if ! echo "$TOOL_PARAMS" | grep -q "Gemfile"; then
20
+ exit 0
21
+ fi
22
+
23
+ # Check if this is a Ruby project
24
+ if [ ! -f "Gemfile" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ echo "Gemfile modified, running bundle install..."
29
+
30
+ # Run bundle install
31
+ if command -v bundle >/dev/null 2>&1; then
32
+ bundle install
33
+ BUNDLE_EXIT_CODE=$?
34
+
35
+ if [ $BUNDLE_EXIT_CODE -eq 0 ]; then
36
+ cat << EOF
37
+ {
38
+ "type": "advanced",
39
+ "decision": "allow",
40
+ "message": "Bundle install completed successfully after Gemfile modification."
41
+ }
42
+ EOF
43
+ else
44
+ cat << EOF
45
+ {
46
+ "type": "advanced",
47
+ "decision": "allow",
48
+ "message": "Bundle install failed. You may need to run 'bundle install' manually.\n\nError code: $BUNDLE_EXIT_CODE"
49
+ }
50
+ EOF
51
+ fi
52
+ else
53
+ cat << EOF
54
+ {
55
+ "type": "advanced",
56
+ "decision": "allow",
57
+ "message": "Bundler not found. Please install bundler and run 'bundle install' manually."
58
+ }
59
+ EOF
60
+ fi
61
+
62
+ # Always allow the modification to proceed
63
+ exit 0
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # Post-tool-use hook generated by Aircana
3
+ # This hook runs after a tool has been executed
4
+
5
+ # Get tool execution details
6
+ TOOL_NAME="$1"
7
+ TOOL_PARAMS="$2"
8
+ TOOL_RESULT="$3"
9
+ EXIT_CODE="$4"
10
+
11
+ # Log the tool execution
12
+ echo "$(date): Tool '$TOOL_NAME' completed with exit code: $EXIT_CODE" >> ~/.aircana/hooks.log
13
+
14
+ # Initialize context for response
15
+ ADDITIONAL_CONTEXT=""
16
+
17
+ # Handle specific tools
18
+ case "$TOOL_NAME" in
19
+ "Edit"|"Write")
20
+ # Auto-update relevant files context if code files were modified
21
+ if echo "$TOOL_PARAMS" | grep -E "\.(rb|js|py|ts)$" && [ "$EXIT_CODE" -eq 0 ]; then
22
+ ADDITIONAL_CONTEXT="Code file modified - consider updating relevant files context."
23
+ fi
24
+ ;;
25
+ "Bash")
26
+ # Handle bash command results
27
+ if [ "$EXIT_CODE" -ne 0 ]; then
28
+ echo "Bash command failed: $TOOL_PARAMS" >> ~/.aircana/hooks.log
29
+ ADDITIONAL_CONTEXT="Previous bash command failed with exit code $EXIT_CODE."
30
+ fi
31
+ ;;
32
+ esac
33
+
34
+ # Output appropriate JSON response
35
+ if [ -n "$ADDITIONAL_CONTEXT" ]; then
36
+ # Escape context for JSON
37
+ ESCAPED_CONTEXT=$(echo -n "$ADDITIONAL_CONTEXT" | sed 's/"/\\"/g')
38
+ cat << EOF
39
+ {
40
+ "hookSpecificOutput": {
41
+ "hookEventName": "PostToolUse",
42
+ "additionalContext": "$ESCAPED_CONTEXT"
43
+ }
44
+ }
45
+ EOF
46
+ else
47
+ # No additional context needed
48
+ echo "{}"
49
+ fi
@@ -0,0 +1,43 @@
1
+ #!/bin/bash
2
+ # Pre-tool-use hook generated by Aircana
3
+ # This hook runs before any tool is executed
4
+
5
+ # Get the tool name and parameters
6
+ TOOL_NAME="$1"
7
+ TOOL_PARAMS="$2"
8
+
9
+ # Log the tool usage (optional)
10
+ echo "$(date): About to use tool: $TOOL_NAME" >> ~/.aircana/hooks.log
11
+
12
+ # Basic validation example
13
+ case "$TOOL_NAME" in
14
+ "Bash")
15
+ # Add validation for bash commands
16
+ if echo "$TOOL_PARAMS" | grep -q "rm -rf /"; then
17
+ cat << EOF
18
+ {
19
+ "hookSpecificOutput": {
20
+ "hookEventName": "PreToolUse",
21
+ "permissionDecision": "deny",
22
+ "permissionDecisionReason": "Dangerous command detected: rm -rf /"
23
+ }
24
+ }
25
+ EOF
26
+ exit 0
27
+ fi
28
+ ;;
29
+ "Edit"|"Write")
30
+ # Validate file operations
31
+ # Add custom validation logic here
32
+ ;;
33
+ esac
34
+
35
+ # Allow the tool to proceed
36
+ cat << EOF
37
+ {
38
+ "hookSpecificOutput": {
39
+ "hookEventName": "PreToolUse",
40
+ "permissionDecision": "allow"
41
+ }
42
+ }
43
+ EOF
@@ -0,0 +1,72 @@
1
+ #!/bin/bash
2
+ # RSpec test hook generated by Aircana
3
+ # This hook runs relevant tests when Ruby files are modified
4
+
5
+ # Get the tool details
6
+ TOOL_NAME="$1"
7
+ TOOL_PARAMS="$2"
8
+
9
+ # Only act on file modification tools
10
+ case "$TOOL_NAME" in
11
+ "Edit"|"Write"|"MultiEdit")
12
+ ;;
13
+ *)
14
+ exit 0
15
+ ;;
16
+ esac
17
+
18
+ # Check if this is a Ruby project with RSpec
19
+ if [ ! -f "Gemfile" ] || ! grep -q "rspec" Gemfile 2>/dev/null; then
20
+ exit 0
21
+ fi
22
+
23
+ # Extract file path from tool parameters (basic extraction)
24
+ MODIFIED_FILE=$(echo "$TOOL_PARAMS" | grep -o '"[^"]*\.rb"' | head -1 | tr -d '"')
25
+
26
+ if [ -z "$MODIFIED_FILE" ]; then
27
+ exit 0
28
+ fi
29
+
30
+ # Determine which specs to run based on the modified file
31
+ SPEC_FILE=""
32
+
33
+ # Check for corresponding spec file
34
+ if echo "$MODIFIED_FILE" | grep -q "^lib/"; then
35
+ # For lib files, look for spec file
36
+ SPEC_FILE="spec/$(echo "$MODIFIED_FILE" | sed 's|^lib/||' | sed 's|\.rb$|_spec.rb|')"
37
+ elif echo "$MODIFIED_FILE" | grep -q "^app/"; then
38
+ # For Rails app files
39
+ SPEC_FILE="spec/$(echo "$MODIFIED_FILE" | sed 's|^app/||' | sed 's|\.rb$|_spec.rb|')"
40
+ elif echo "$MODIFIED_FILE" | grep -q "_spec\.rb$"; then
41
+ # If the modified file is already a spec file
42
+ SPEC_FILE="$MODIFIED_FILE"
43
+ fi
44
+
45
+ # Check if spec file exists
46
+ if [ -n "$SPEC_FILE" ] && [ -f "$SPEC_FILE" ]; then
47
+ echo "Running tests for modified file: $MODIFIED_FILE"
48
+
49
+ # Run the specific test
50
+ if command -v bundle >/dev/null 2>&1; then
51
+ bundle exec rspec "$SPEC_FILE" --format progress
52
+ else
53
+ rspec "$SPEC_FILE" --format progress
54
+ fi
55
+
56
+ RSPEC_EXIT_CODE=$?
57
+
58
+ if [ $RSPEC_EXIT_CODE -ne 0 ]; then
59
+ cat << EOF
60
+ {
61
+ "type": "advanced",
62
+ "decision": "allow",
63
+ "message": "Tests failed for $SPEC_FILE. Consider fixing the failing tests.\n\nYou can run: bundle exec rspec $SPEC_FILE"
64
+ }
65
+ EOF
66
+ else
67
+ echo "Tests passed for $SPEC_FILE"
68
+ fi
69
+ fi
70
+
71
+ # Always allow the modification to proceed
72
+ exit 0
@@ -0,0 +1,55 @@
1
+ #!/bin/bash
2
+ # RuboCop pre-commit hook generated by Aircana
3
+ # This hook runs RuboCop before allowing commits
4
+
5
+ # Get the tool name and parameters
6
+ TOOL_NAME="$1"
7
+ TOOL_PARAMS="$2"
8
+
9
+ # Only act on Bash commands that look like git commits
10
+ if [ "$TOOL_NAME" != "Bash" ]; then
11
+ exit 0
12
+ fi
13
+
14
+ # Check if this is a git commit command
15
+ if ! echo "$TOOL_PARAMS" | grep -q "git commit"; then
16
+ exit 0
17
+ fi
18
+
19
+ # Check if we're in a Ruby project
20
+ if [ ! -f "Gemfile" ] && [ ! -f ".rubocop.yml" ]; then
21
+ exit 0
22
+ fi
23
+
24
+ # Get list of staged Ruby files
25
+ RUBY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(rb)$' | tr '\n' ' ')
26
+
27
+ if [ -z "$RUBY_FILES" ]; then
28
+ # No Ruby files to check
29
+ exit 0
30
+ fi
31
+
32
+ echo "Running RuboCop on staged Ruby files..."
33
+
34
+ # Run RuboCop on staged files
35
+ if command -v bundle >/dev/null 2>&1 && [ -f "Gemfile" ]; then
36
+ bundle exec rubocop $RUBY_FILES
37
+ else
38
+ rubocop $RUBY_FILES
39
+ fi
40
+
41
+ RUBOCOP_EXIT_CODE=$?
42
+
43
+ if [ $RUBOCOP_EXIT_CODE -ne 0 ]; then
44
+ cat << EOF
45
+ {
46
+ "type": "advanced",
47
+ "decision": "deny",
48
+ "message": "RuboCop found style violations. Please fix them before committing.\n\nYou can run: bundle exec rubocop $RUBY_FILES --auto-correct\n\nOr fix them manually and try again."
49
+ }
50
+ EOF
51
+ exit 1
52
+ fi
53
+
54
+ # RuboCop passed, allow the commit
55
+ exit 0