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
@@ -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
|
@@ -3,6 +3,7 @@
|
|
3
3
|
require "httparty"
|
4
4
|
require "reverse_markdown"
|
5
5
|
require_relative "local"
|
6
|
+
require_relative "manifest"
|
6
7
|
require_relative "confluence_logging"
|
7
8
|
require_relative "confluence_http"
|
8
9
|
require_relative "confluence_content"
|
@@ -28,10 +29,36 @@ module Aircana
|
|
28
29
|
setup_httparty
|
29
30
|
|
30
31
|
pages = search_and_log_pages(agent)
|
31
|
-
return 0 if pages.empty?
|
32
|
+
return { pages_count: 0, sources: [] } if pages.empty?
|
32
33
|
|
33
|
-
|
34
|
-
|
34
|
+
sources = process_pages_with_manifest(pages, agent)
|
35
|
+
create_or_update_manifest(agent, sources)
|
36
|
+
|
37
|
+
{ pages_count: pages.size, sources: sources }
|
38
|
+
end
|
39
|
+
|
40
|
+
def refresh_from_manifest(agent:)
|
41
|
+
sources = Manifest.sources_from_manifest(agent)
|
42
|
+
return { pages_count: 0, sources: [] } if sources.empty?
|
43
|
+
|
44
|
+
validate_configuration!
|
45
|
+
setup_httparty
|
46
|
+
|
47
|
+
confluence_sources = sources.select { |s| s["type"] == "confluence" }
|
48
|
+
return { pages_count: 0, sources: [] } if confluence_sources.empty?
|
49
|
+
|
50
|
+
all_pages = []
|
51
|
+
confluence_sources.each do |source|
|
52
|
+
pages = fetch_pages_from_source(source)
|
53
|
+
all_pages.concat(pages)
|
54
|
+
end
|
55
|
+
|
56
|
+
return { pages_count: 0, sources: [] } if all_pages.empty?
|
57
|
+
|
58
|
+
updated_sources = process_pages_with_manifest(all_pages, agent)
|
59
|
+
Manifest.update_manifest(agent, updated_sources)
|
60
|
+
|
61
|
+
{ pages_count: all_pages.size, sources: updated_sources }
|
35
62
|
end
|
36
63
|
|
37
64
|
def search_and_log_pages(agent)
|
@@ -48,8 +75,54 @@ module Aircana
|
|
48
75
|
end
|
49
76
|
end
|
50
77
|
|
78
|
+
def process_pages_with_manifest(pages, agent)
|
79
|
+
page_metadata = []
|
80
|
+
|
81
|
+
ProgressTracker.with_batch_progress(pages, "Processing pages") do |page, _index|
|
82
|
+
store_page_as_markdown(page, agent)
|
83
|
+
page_metadata << extract_page_metadata(page)
|
84
|
+
end
|
85
|
+
|
86
|
+
build_source_metadata(agent, page_metadata)
|
87
|
+
end
|
88
|
+
|
51
89
|
private
|
52
90
|
|
91
|
+
def fetch_pages_from_source(source)
|
92
|
+
case source["type"]
|
93
|
+
when "confluence"
|
94
|
+
fetch_pages_by_label(source["label"])
|
95
|
+
else
|
96
|
+
[]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def extract_page_metadata(page)
|
101
|
+
{
|
102
|
+
"id" => page["id"],
|
103
|
+
"title" => page["title"],
|
104
|
+
"last_updated" => page.dig("version", "when") || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
105
|
+
}
|
106
|
+
end
|
107
|
+
|
108
|
+
def build_source_metadata(agent, page_metadata)
|
109
|
+
[
|
110
|
+
{
|
111
|
+
"type" => "confluence",
|
112
|
+
"label" => agent,
|
113
|
+
"pages" => page_metadata
|
114
|
+
}
|
115
|
+
]
|
116
|
+
end
|
117
|
+
|
118
|
+
def create_or_update_manifest(agent, sources)
|
119
|
+
if Manifest.manifest_exists?(agent)
|
120
|
+
Manifest.update_manifest(agent, sources)
|
121
|
+
else
|
122
|
+
Manifest.create_manifest(agent, sources)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
53
126
|
def validate_configuration!
|
54
127
|
config = Aircana.configuration
|
55
128
|
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module Aircana
|
7
|
+
module Contexts
|
8
|
+
class Manifest
|
9
|
+
class << self
|
10
|
+
def create_manifest(agent, sources)
|
11
|
+
validate_sources(sources)
|
12
|
+
|
13
|
+
manifest_path = manifest_path_for(agent)
|
14
|
+
manifest_data = build_manifest_data(agent, sources)
|
15
|
+
|
16
|
+
FileUtils.mkdir_p(File.dirname(manifest_path))
|
17
|
+
File.write(manifest_path, JSON.pretty_generate(manifest_data))
|
18
|
+
|
19
|
+
Aircana.human_logger.info "Created knowledge manifest for agent '#{agent}'"
|
20
|
+
manifest_path
|
21
|
+
end
|
22
|
+
|
23
|
+
def update_manifest(agent, sources)
|
24
|
+
validate_sources(sources)
|
25
|
+
|
26
|
+
manifest_path = manifest_path_for(agent)
|
27
|
+
|
28
|
+
if File.exist?(manifest_path)
|
29
|
+
existing_data = JSON.parse(File.read(manifest_path))
|
30
|
+
manifest_data = existing_data.merge({
|
31
|
+
"last_updated" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
32
|
+
"sources" => sources
|
33
|
+
})
|
34
|
+
else
|
35
|
+
manifest_data = build_manifest_data(agent, sources)
|
36
|
+
end
|
37
|
+
|
38
|
+
FileUtils.mkdir_p(File.dirname(manifest_path))
|
39
|
+
File.write(manifest_path, JSON.pretty_generate(manifest_data))
|
40
|
+
manifest_path
|
41
|
+
end
|
42
|
+
|
43
|
+
def read_manifest(agent)
|
44
|
+
manifest_path = manifest_path_for(agent)
|
45
|
+
return nil unless File.exist?(manifest_path)
|
46
|
+
|
47
|
+
begin
|
48
|
+
manifest_data = JSON.parse(File.read(manifest_path))
|
49
|
+
validate_manifest(manifest_data)
|
50
|
+
manifest_data
|
51
|
+
rescue JSON::ParserError => e
|
52
|
+
Aircana.human_logger.warn "Invalid manifest for agent '#{agent}': #{e.message}"
|
53
|
+
nil
|
54
|
+
rescue ManifestError => e
|
55
|
+
Aircana.human_logger.warn "Manifest validation failed for agent '#{agent}': #{e.message}"
|
56
|
+
nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def sources_from_manifest(agent)
|
61
|
+
manifest = read_manifest(agent)
|
62
|
+
return [] unless manifest
|
63
|
+
|
64
|
+
manifest["sources"] || []
|
65
|
+
end
|
66
|
+
|
67
|
+
def manifest_exists?(agent)
|
68
|
+
File.exist?(manifest_path_for(agent))
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def manifest_path_for(agent)
|
74
|
+
resolved_agent_path = resolve_agent_path(agent)
|
75
|
+
File.join(resolved_agent_path, "manifest.json")
|
76
|
+
end
|
77
|
+
|
78
|
+
def resolve_agent_path(agent)
|
79
|
+
base_path = File.join(Aircana.configuration.agent_knowledge_dir, agent)
|
80
|
+
|
81
|
+
# If this is a symlink (multi-root scenario), resolve to original
|
82
|
+
if File.symlink?(base_path)
|
83
|
+
File.readlink(base_path)
|
84
|
+
else
|
85
|
+
base_path
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def build_manifest_data(agent, sources)
|
90
|
+
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
91
|
+
|
92
|
+
{
|
93
|
+
"version" => "1.0",
|
94
|
+
"agent" => agent,
|
95
|
+
"created" => timestamp,
|
96
|
+
"last_updated" => timestamp,
|
97
|
+
"sources" => sources
|
98
|
+
}
|
99
|
+
end
|
100
|
+
|
101
|
+
def validate_manifest(manifest_data)
|
102
|
+
required_fields = %w[version agent sources]
|
103
|
+
|
104
|
+
required_fields.each do |field|
|
105
|
+
raise ManifestError, "Missing required field: #{field}" unless manifest_data.key?(field)
|
106
|
+
end
|
107
|
+
|
108
|
+
unless manifest_data["version"] == "1.0"
|
109
|
+
raise ManifestError, "Unsupported manifest version: #{manifest_data["version"]}"
|
110
|
+
end
|
111
|
+
|
112
|
+
validate_sources(manifest_data["sources"])
|
113
|
+
end
|
114
|
+
|
115
|
+
def validate_sources(sources)
|
116
|
+
raise ManifestError, "Sources must be an array" unless sources.is_a?(Array)
|
117
|
+
|
118
|
+
sources.each do |source|
|
119
|
+
validate_source(source)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def validate_source(source)
|
124
|
+
raise ManifestError, "Each source must be a hash" unless source.is_a?(Hash)
|
125
|
+
|
126
|
+
raise ManifestError, "Source missing required field: type" unless source.key?("type")
|
127
|
+
|
128
|
+
case source["type"]
|
129
|
+
when "confluence"
|
130
|
+
validate_confluence_source(source)
|
131
|
+
else
|
132
|
+
raise ManifestError, "Unknown source type: #{source["type"]}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def validate_confluence_source(source)
|
137
|
+
raise ManifestError, "Confluence source missing required field: label" unless source.key?("label")
|
138
|
+
|
139
|
+
return unless source.key?("pages") && !source["pages"].is_a?(Array)
|
140
|
+
|
141
|
+
raise ManifestError, "Confluence pages must be an array"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
class ManifestError < StandardError; end
|
147
|
+
end
|
148
|
+
end
|