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.
@@ -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.gsub(/.*::/, "").gsub(/^[A-Z]/) do |match|
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
@@ -68,7 +68,7 @@ module Aircana
68
68
  end
69
69
 
70
70
  def knowledge_path
71
- ".aircana/agents/#{agent_name}/knowledge"
71
+ ".aircana/agents/#{agent_name}/knowledge/"
72
72
  end
73
73
  end
74
74
  end
@@ -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