aircana 3.2.1 → 4.0.0.rc2
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 +168 -153
- data/CHANGELOG.md +55 -0
- data/CLAUDE.md +50 -39
- data/README.md +70 -76
- data/lib/aircana/cli/app.rb +16 -21
- data/lib/aircana/cli/commands/generate.rb +0 -12
- data/lib/aircana/cli/commands/kb.rb +590 -0
- data/lib/aircana/cli/help_formatter.rb +5 -4
- data/lib/aircana/configuration.rb +13 -28
- data/lib/aircana/contexts/confluence.rb +55 -24
- data/lib/aircana/contexts/confluence_content.rb +48 -5
- data/lib/aircana/contexts/local.rb +8 -9
- data/lib/aircana/contexts/manifest.rb +46 -34
- data/lib/aircana/contexts/web.rb +47 -17
- data/lib/aircana/generators/skills_generator.rb +194 -0
- data/lib/aircana/templates/skills/base_skill.erb +12 -0
- data/lib/aircana/version.rb +1 -1
- metadata +4 -18
- data/lib/aircana/cli/commands/agents.rb +0 -733
- data/lib/aircana/generators/agents_generator.rb +0 -79
- data/lib/aircana/templates/agents/base_agent.erb +0 -31
- data/lib/aircana/templates/agents/defaults/apply_feedback.erb +0 -91
- data/lib/aircana/templates/agents/defaults/executor.erb +0 -84
- data/lib/aircana/templates/agents/defaults/jira.erb +0 -45
- data/lib/aircana/templates/agents/defaults/planner.erb +0 -63
- data/lib/aircana/templates/agents/defaults/reviewer.erb +0 -94
- data/lib/aircana/templates/agents/defaults/sub-agent-coordinator.erb +0 -90
- data/lib/aircana/templates/hooks/refresh_agents.erb +0 -66
- data/lib/aircana/templates/hooks/sync_local_knowledge.erb +0 -86
- data/spec_target_1760656566_428/agents/test-agent/manifest.json +0 -16
- data/spec_target_1760656588_38/agents/test-agent/manifest.json +0 -16
- data/spec_target_1760656647_612/agents/test-agent/manifest.json +0 -16
- data/spec_target_1760656660_113/agents/test-agent/manifest.json +0 -16
- data/spec_target_1760656689_268/agents/test-agent/manifest.json +0 -16
- data/spec_target_1760656710_387/agents/test-agent/manifest.json +0 -16
@@ -6,7 +6,6 @@ require_relative "../../generators/execute_command_generator"
|
|
6
6
|
require_relative "../../generators/review_command_generator"
|
7
7
|
require_relative "../../generators/apply_feedback_command_generator"
|
8
8
|
require_relative "../../generators/ask_expert_command_generator"
|
9
|
-
require_relative "../../generators/agents_generator"
|
10
9
|
require_relative "../../generators/hooks_generator"
|
11
10
|
|
12
11
|
module Aircana
|
@@ -27,7 +26,6 @@ module Aircana
|
|
27
26
|
def run
|
28
27
|
clean_output_directories
|
29
28
|
generators.each(&:generate)
|
30
|
-
generate_default_agents
|
31
29
|
generate_default_hooks
|
32
30
|
Aircana.human_logger.success("Re-generated #{Aircana.configuration.output_dir} files.")
|
33
31
|
end
|
@@ -38,16 +36,6 @@ module Aircana
|
|
38
36
|
# Remove stale command files to prevent duplicates during init
|
39
37
|
commands_dir = File.join(Aircana.configuration.output_dir, "commands")
|
40
38
|
FileUtils.rm_rf(Dir.glob("#{commands_dir}/*")) if Dir.exist?(commands_dir)
|
41
|
-
|
42
|
-
# Remove stale agent files for consistency
|
43
|
-
agents_dir = File.join(Aircana.configuration.output_dir, "agents")
|
44
|
-
FileUtils.rm_rf(Dir.glob("#{agents_dir}/*")) if Dir.exist?(agents_dir)
|
45
|
-
end
|
46
|
-
|
47
|
-
def generate_default_agents
|
48
|
-
Aircana::Generators::AgentsGenerator.available_default_agents.each do |agent_name|
|
49
|
-
Aircana::Generators::AgentsGenerator.create_default_agent(agent_name)
|
50
|
-
end
|
51
39
|
end
|
52
40
|
|
53
41
|
def generate_default_hooks
|
@@ -0,0 +1,590 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "tty-prompt"
|
5
|
+
require_relative "../../generators/skills_generator"
|
6
|
+
require_relative "../../contexts/manifest"
|
7
|
+
require_relative "../../contexts/web"
|
8
|
+
|
9
|
+
module Aircana
|
10
|
+
module CLI
|
11
|
+
module KB # rubocop:disable Metrics/ModuleLength
|
12
|
+
class << self # rubocop:disable Metrics/ClassLength
|
13
|
+
def refresh(kb_name)
|
14
|
+
normalized_kb_name = normalize_string(kb_name)
|
15
|
+
|
16
|
+
# Check if this is a local knowledge base
|
17
|
+
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_kb_name)
|
18
|
+
if kb_type == "local"
|
19
|
+
Aircana.human_logger.info "⊘ Skipping #{normalized_kb_name} (local knowledge base - no refresh needed)"
|
20
|
+
return
|
21
|
+
end
|
22
|
+
|
23
|
+
perform_manifest_aware_refresh(normalized_kb_name)
|
24
|
+
regenerate_skill_md(normalized_kb_name)
|
25
|
+
rescue Aircana::Error => e
|
26
|
+
handle_refresh_error(normalized_kb_name, e)
|
27
|
+
end
|
28
|
+
|
29
|
+
def create # rubocop:disable Metrics/MethodLength
|
30
|
+
prompt = TTY::Prompt.new
|
31
|
+
|
32
|
+
kb_name = prompt.ask("What topic should this knowledge base cover?",
|
33
|
+
default: "e.g., 'Canvas Backend Database', 'API Design'")
|
34
|
+
short_description = prompt.ask("Briefly describe what this KB contains:")
|
35
|
+
|
36
|
+
# Prompt for knowledge base type
|
37
|
+
kb_type = prompt.select("Knowledge base type:", [
|
38
|
+
{
|
39
|
+
name: "Local - Version controlled, no refresh needed",
|
40
|
+
value: "local"
|
41
|
+
},
|
42
|
+
{
|
43
|
+
name: "Remote - Fetched from Confluence/web, " \
|
44
|
+
"auto-refreshed via SessionStart hook",
|
45
|
+
value: "remote"
|
46
|
+
}
|
47
|
+
])
|
48
|
+
|
49
|
+
normalized_kb_name = normalize_string(kb_name)
|
50
|
+
|
51
|
+
# Prompt for knowledge fetching
|
52
|
+
fetched_confluence = prompt_for_knowledge_fetch(prompt, normalized_kb_name, kb_type, short_description)
|
53
|
+
|
54
|
+
# Prompt for web URL fetching
|
55
|
+
fetched_urls = prompt_for_url_fetch(prompt, normalized_kb_name, kb_type)
|
56
|
+
|
57
|
+
# Generate SKILL.md if no content was fetched during the prompts
|
58
|
+
# (the prompt functions already generate it when they successfully fetch content)
|
59
|
+
regenerate_skill_md(normalized_kb_name, short_description) unless fetched_confluence || fetched_urls
|
60
|
+
|
61
|
+
# If remote kb_type, ensure SessionStart hook is installed
|
62
|
+
ensure_remote_knowledge_refresh_hook if kb_type == "remote"
|
63
|
+
|
64
|
+
# Ensure gitignore is configured
|
65
|
+
ensure_gitignore_entry(kb_type)
|
66
|
+
|
67
|
+
Aircana.human_logger.success "Knowledge base '#{kb_name}' setup complete!"
|
68
|
+
end
|
69
|
+
|
70
|
+
def list
|
71
|
+
kb_dir = Aircana.configuration.kb_knowledge_dir
|
72
|
+
return print_no_kbs_message unless Dir.exist?(kb_dir)
|
73
|
+
|
74
|
+
kb_folders = find_kb_folders(kb_dir)
|
75
|
+
return print_no_kbs_message if kb_folders.empty?
|
76
|
+
|
77
|
+
print_kbs_list(kb_folders)
|
78
|
+
end
|
79
|
+
|
80
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
81
|
+
def add_url(kb_name, url)
|
82
|
+
normalized_kb_name = normalize_string(kb_name)
|
83
|
+
|
84
|
+
unless kb_exists?(normalized_kb_name)
|
85
|
+
Aircana.human_logger.error "KB '#{kb_name}' not found. Use 'aircana kb list' to see available KBs."
|
86
|
+
exit 1
|
87
|
+
end
|
88
|
+
|
89
|
+
# Get kb_type from manifest
|
90
|
+
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_kb_name)
|
91
|
+
|
92
|
+
web = Aircana::Contexts::Web.new
|
93
|
+
result = web.fetch_url_for(kb_name: normalized_kb_name, url: url, kb_type: kb_type)
|
94
|
+
|
95
|
+
if result
|
96
|
+
# Update manifest with the new URL
|
97
|
+
existing_sources = Aircana::Contexts::Manifest.sources_from_manifest(normalized_kb_name)
|
98
|
+
web_sources = existing_sources.select { |s| s["type"] == "web" }
|
99
|
+
other_sources = existing_sources.reject { |s| s["type"] == "web" }
|
100
|
+
|
101
|
+
if web_sources.any?
|
102
|
+
# Add to existing web source
|
103
|
+
web_sources.first["urls"] << result
|
104
|
+
else
|
105
|
+
# Create new web source
|
106
|
+
web_sources = [{ "type" => "web", "urls" => [result] }]
|
107
|
+
end
|
108
|
+
|
109
|
+
all_sources = other_sources + web_sources
|
110
|
+
Aircana::Contexts::Manifest.update_manifest(normalized_kb_name, all_sources)
|
111
|
+
|
112
|
+
# Regenerate SKILL.md
|
113
|
+
regenerate_skill_md(normalized_kb_name)
|
114
|
+
|
115
|
+
Aircana.human_logger.success "Successfully added URL to KB '#{kb_name}'"
|
116
|
+
else
|
117
|
+
Aircana.human_logger.error "Failed to fetch URL: #{url}"
|
118
|
+
exit 1
|
119
|
+
end
|
120
|
+
rescue Aircana::Error => e
|
121
|
+
Aircana.human_logger.error "Failed to add URL: #{e.message}"
|
122
|
+
exit 1
|
123
|
+
end
|
124
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
|
125
|
+
|
126
|
+
def refresh_all # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
127
|
+
kb_names = all_kbs
|
128
|
+
|
129
|
+
if kb_names.empty?
|
130
|
+
Aircana.human_logger.info "No knowledge bases found to refresh."
|
131
|
+
return
|
132
|
+
end
|
133
|
+
|
134
|
+
Aircana.human_logger.info "Starting refresh for #{kb_names.size} KB(s)..."
|
135
|
+
|
136
|
+
results = {
|
137
|
+
total: kb_names.size,
|
138
|
+
successful: 0,
|
139
|
+
failed: 0,
|
140
|
+
skipped: 0,
|
141
|
+
total_pages: 0,
|
142
|
+
failed_kbs: [],
|
143
|
+
skipped_kbs: []
|
144
|
+
}
|
145
|
+
|
146
|
+
kb_names.each do |kb_name|
|
147
|
+
# Check if this is a local knowledge base
|
148
|
+
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(kb_name)
|
149
|
+
if kb_type == "local"
|
150
|
+
Aircana.human_logger.info "⊘ Skipping #{kb_name} (local knowledge base)"
|
151
|
+
results[:skipped] += 1
|
152
|
+
results[:skipped_kbs] << kb_name
|
153
|
+
next
|
154
|
+
end
|
155
|
+
|
156
|
+
result = refresh_single_kb(kb_name)
|
157
|
+
if result[:success]
|
158
|
+
results[:successful] += 1
|
159
|
+
results[:total_pages] += result[:pages_count]
|
160
|
+
else
|
161
|
+
results[:failed] += 1
|
162
|
+
results[:failed_kbs] << { name: kb_name, error: result[:error] }
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
print_refresh_all_summary(results)
|
167
|
+
end
|
168
|
+
|
169
|
+
private
|
170
|
+
|
171
|
+
def perform_refresh(normalized_kb_name, kb_type, label: nil)
|
172
|
+
confluence = Aircana::Contexts::Confluence.new
|
173
|
+
result = confluence.fetch_pages_for(kb_name: normalized_kb_name, kb_type: kb_type, label: label)
|
174
|
+
|
175
|
+
log_refresh_result(normalized_kb_name, result[:pages_count])
|
176
|
+
result
|
177
|
+
end
|
178
|
+
|
179
|
+
def log_refresh_result(normalized_kb_name, pages_count)
|
180
|
+
if pages_count.positive?
|
181
|
+
Aircana.human_logger.success "Successfully refreshed #{pages_count} pages for KB '#{normalized_kb_name}'"
|
182
|
+
else
|
183
|
+
log_no_pages_found(normalized_kb_name)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
188
|
+
def perform_manifest_aware_refresh(normalized_kb_name)
|
189
|
+
total_pages = 0
|
190
|
+
all_sources = []
|
191
|
+
|
192
|
+
# Try manifest-based refresh first
|
193
|
+
if Aircana::Contexts::Manifest.manifest_exists?(normalized_kb_name)
|
194
|
+
Aircana.human_logger.info "Refreshing from knowledge manifest..."
|
195
|
+
|
196
|
+
# Refresh Confluence sources
|
197
|
+
confluence = Aircana::Contexts::Confluence.new
|
198
|
+
confluence_result = confluence.refresh_from_manifest(kb_name: normalized_kb_name)
|
199
|
+
total_pages += confluence_result[:pages_count]
|
200
|
+
all_sources.concat(confluence_result[:sources])
|
201
|
+
|
202
|
+
# Refresh web sources
|
203
|
+
web = Aircana::Contexts::Web.new
|
204
|
+
web_result = web.refresh_web_sources(kb_name: normalized_kb_name)
|
205
|
+
total_pages += web_result[:pages_count]
|
206
|
+
all_sources.concat(web_result[:sources])
|
207
|
+
else
|
208
|
+
Aircana.human_logger.info "No manifest found, falling back to label-based search..."
|
209
|
+
kb_type = "remote" # Default to remote if no manifest
|
210
|
+
confluence = Aircana::Contexts::Confluence.new
|
211
|
+
confluence_result = confluence.fetch_pages_for(kb_name: normalized_kb_name, kb_type: kb_type)
|
212
|
+
total_pages += confluence_result[:pages_count]
|
213
|
+
all_sources.concat(confluence_result[:sources])
|
214
|
+
end
|
215
|
+
|
216
|
+
# Update manifest with all sources combined
|
217
|
+
Aircana::Contexts::Manifest.update_manifest(normalized_kb_name, all_sources) if all_sources.any?
|
218
|
+
|
219
|
+
log_refresh_result(normalized_kb_name, total_pages)
|
220
|
+
{ pages_count: total_pages, sources: all_sources }
|
221
|
+
end
|
222
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
223
|
+
|
224
|
+
# rubocop:disable Metrics/MethodLength
|
225
|
+
def regenerate_skill_md(kb_name, short_description = nil)
|
226
|
+
return unless Aircana::Contexts::Manifest.manifest_exists?(kb_name)
|
227
|
+
|
228
|
+
generator = if short_description
|
229
|
+
Generators::SkillsGenerator.new(
|
230
|
+
kb_name: kb_name,
|
231
|
+
short_description: short_description
|
232
|
+
)
|
233
|
+
else
|
234
|
+
Generators::SkillsGenerator.from_manifest(kb_name)
|
235
|
+
end
|
236
|
+
|
237
|
+
generator.generate
|
238
|
+
Aircana.human_logger.success "Generated SKILL.md for '#{kb_name}'"
|
239
|
+
rescue StandardError => e
|
240
|
+
Aircana.human_logger.warn "Failed to generate SKILL.md: #{e.message}"
|
241
|
+
end
|
242
|
+
# rubocop:enable Metrics/MethodLength
|
243
|
+
|
244
|
+
def ensure_gitignore_entry(kb_type)
|
245
|
+
gitignore_path = gitignore_file_path
|
246
|
+
|
247
|
+
if kb_type == "remote"
|
248
|
+
# For remote KBs, ensure knowledge files are ignored
|
249
|
+
ensure_remote_knowledge_ignored(gitignore_path)
|
250
|
+
else
|
251
|
+
# For local KBs, ensure skills directory is NOT ignored
|
252
|
+
ensure_local_knowledge_not_ignored(gitignore_path)
|
253
|
+
end
|
254
|
+
rescue StandardError => e
|
255
|
+
Aircana.human_logger.warn "Could not update .gitignore: #{e.message}"
|
256
|
+
end
|
257
|
+
|
258
|
+
def ensure_remote_knowledge_ignored(gitignore_path)
|
259
|
+
pattern = remote_knowledge_pattern
|
260
|
+
return if gitignore_has_pattern?(gitignore_path, pattern)
|
261
|
+
|
262
|
+
append_to_gitignore(gitignore_path, pattern)
|
263
|
+
Aircana.human_logger.success "Added remote knowledge files to .gitignore"
|
264
|
+
end
|
265
|
+
|
266
|
+
def ensure_local_knowledge_not_ignored(gitignore_path)
|
267
|
+
negation_pattern = local_knowledge_negation_pattern
|
268
|
+
return if gitignore_has_pattern?(gitignore_path, negation_pattern)
|
269
|
+
|
270
|
+
# Add comment and negation pattern
|
271
|
+
comment = "# Local KB knowledge IS version controlled (don't ignore)"
|
272
|
+
content_to_append = "\n#{comment}\n#{negation_pattern}\n"
|
273
|
+
|
274
|
+
existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
275
|
+
needs_newline = !existing_content.empty? && !existing_content.end_with?("\n")
|
276
|
+
content_to_append = "\n#{content_to_append}" if needs_newline
|
277
|
+
|
278
|
+
File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
|
279
|
+
Aircana.human_logger.success "Added local knowledge negation to .gitignore"
|
280
|
+
end
|
281
|
+
|
282
|
+
def gitignore_file_path
|
283
|
+
File.join(Aircana.configuration.project_dir, ".gitignore")
|
284
|
+
end
|
285
|
+
|
286
|
+
def remote_knowledge_pattern
|
287
|
+
".claude/skills/*/*.md"
|
288
|
+
end
|
289
|
+
|
290
|
+
def local_knowledge_negation_pattern
|
291
|
+
"!.claude/skills/*/*.md"
|
292
|
+
end
|
293
|
+
|
294
|
+
def gitignore_has_pattern?(gitignore_path, pattern)
|
295
|
+
return false unless File.exist?(gitignore_path)
|
296
|
+
|
297
|
+
content = File.read(gitignore_path)
|
298
|
+
if content.lines.any? { |line| line.strip == pattern }
|
299
|
+
Aircana.human_logger.info "Pattern '#{pattern}' already in .gitignore"
|
300
|
+
true
|
301
|
+
else
|
302
|
+
false
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
def append_to_gitignore(gitignore_path, pattern)
|
307
|
+
existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
308
|
+
content_to_append = existing_content.empty? || existing_content.end_with?("\n") ? "" : "\n"
|
309
|
+
content_to_append += "#{pattern}\n"
|
310
|
+
|
311
|
+
File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
|
312
|
+
end
|
313
|
+
|
314
|
+
def log_no_pages_found(normalized_kb_name)
|
315
|
+
Aircana.human_logger.info "No pages found for KB '#{normalized_kb_name}'. " \
|
316
|
+
"Make sure pages are labeled with '#{normalized_kb_name}' in Confluence."
|
317
|
+
end
|
318
|
+
|
319
|
+
def handle_refresh_error(normalized_kb_name, error)
|
320
|
+
Aircana.human_logger.error "Failed to refresh KB '#{normalized_kb_name}': #{error.message}"
|
321
|
+
exit 1
|
322
|
+
end
|
323
|
+
|
324
|
+
def normalize_string(string)
|
325
|
+
string.strip.downcase.gsub(" ", "-")
|
326
|
+
end
|
327
|
+
|
328
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
329
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
330
|
+
def prompt_for_knowledge_fetch(prompt, normalized_kb_name, kb_type, short_description)
|
331
|
+
return false unless confluence_configured?
|
332
|
+
|
333
|
+
if prompt.yes?("Would you like to fetch knowledge for this KB from Confluence now?")
|
334
|
+
Aircana.human_logger.info "Fetching knowledge from Confluence..."
|
335
|
+
|
336
|
+
# Optionally ask for custom label
|
337
|
+
use_custom_label = prompt.yes?("Use a custom Confluence label? (default: #{normalized_kb_name})")
|
338
|
+
label = if use_custom_label
|
339
|
+
prompt.ask("Enter Confluence label:")
|
340
|
+
else
|
341
|
+
normalized_kb_name
|
342
|
+
end
|
343
|
+
|
344
|
+
result = perform_refresh(normalized_kb_name, kb_type, label: label)
|
345
|
+
if result[:pages_count]&.positive?
|
346
|
+
ensure_gitignore_entry(kb_type)
|
347
|
+
regenerate_skill_md(normalized_kb_name, short_description)
|
348
|
+
return true
|
349
|
+
end
|
350
|
+
else
|
351
|
+
refresh_message = if kb_type == "local"
|
352
|
+
"fetch knowledge"
|
353
|
+
else
|
354
|
+
"run 'aircana kb refresh #{normalized_kb_name}'"
|
355
|
+
end
|
356
|
+
Aircana.human_logger.info(
|
357
|
+
"Skipping knowledge fetch. You can #{refresh_message} later."
|
358
|
+
)
|
359
|
+
end
|
360
|
+
|
361
|
+
false
|
362
|
+
rescue Aircana::Error => e
|
363
|
+
Aircana.human_logger.warn "Failed to fetch knowledge: #{e.message}"
|
364
|
+
refresh_message = if kb_type == "local"
|
365
|
+
"fetch knowledge"
|
366
|
+
else
|
367
|
+
"try again later with 'aircana kb refresh #{normalized_kb_name}'"
|
368
|
+
end
|
369
|
+
Aircana.human_logger.info "You can #{refresh_message}"
|
370
|
+
false
|
371
|
+
end
|
372
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
373
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
374
|
+
|
375
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
376
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
377
|
+
def prompt_for_url_fetch(prompt, normalized_kb_name, kb_type)
|
378
|
+
return false unless prompt.yes?("Would you like to add web URLs for this KB's knowledge base?")
|
379
|
+
|
380
|
+
urls = []
|
381
|
+
loop do
|
382
|
+
url = prompt.ask("Enter URL (or press Enter to finish):")
|
383
|
+
break if url.nil? || url.strip.empty?
|
384
|
+
|
385
|
+
url = url.strip
|
386
|
+
if valid_url?(url)
|
387
|
+
urls << url
|
388
|
+
else
|
389
|
+
Aircana.human_logger.warn "Invalid URL format: #{url}. Please enter a valid HTTP or HTTPS URL."
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
return false if urls.empty?
|
394
|
+
|
395
|
+
begin
|
396
|
+
Aircana.human_logger.info "Fetching #{urls.size} URL(s)..."
|
397
|
+
web = Aircana::Contexts::Web.new
|
398
|
+
result = web.fetch_urls_for(kb_name: normalized_kb_name, urls: urls, kb_type: kb_type)
|
399
|
+
|
400
|
+
if result[:pages_count].positive?
|
401
|
+
Aircana.human_logger.success "Successfully fetched #{result[:pages_count]} URL(s)"
|
402
|
+
ensure_gitignore_entry(kb_type)
|
403
|
+
regenerate_skill_md(normalized_kb_name)
|
404
|
+
return true
|
405
|
+
else
|
406
|
+
Aircana.human_logger.warn "No URLs were successfully fetched"
|
407
|
+
end
|
408
|
+
rescue Aircana::Error => e
|
409
|
+
Aircana.human_logger.warn "Failed to fetch URLs: #{e.message}"
|
410
|
+
Aircana.human_logger.info(
|
411
|
+
"You can add URLs later with 'aircana kb add-url #{normalized_kb_name} <URL>'"
|
412
|
+
)
|
413
|
+
end
|
414
|
+
|
415
|
+
false
|
416
|
+
end
|
417
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
418
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
419
|
+
|
420
|
+
# rubocop:disable Metrics/AbcSize
|
421
|
+
def confluence_configured?
|
422
|
+
config = Aircana.configuration
|
423
|
+
|
424
|
+
base_url_present = !config.confluence_base_url.nil? && !config.confluence_base_url.empty?
|
425
|
+
username_present = !config.confluence_username.nil? && !config.confluence_username.empty?
|
426
|
+
token_present = !config.confluence_api_token.nil? && !config.confluence_api_token.empty?
|
427
|
+
|
428
|
+
base_url_present && username_present && token_present
|
429
|
+
end
|
430
|
+
# rubocop:enable Metrics/AbcSize
|
431
|
+
|
432
|
+
def print_no_kbs_message
|
433
|
+
Aircana.human_logger.info("No knowledge bases configured yet.")
|
434
|
+
end
|
435
|
+
|
436
|
+
def find_kb_folders(kb_dir)
|
437
|
+
Dir.entries(kb_dir).select do |entry|
|
438
|
+
path = File.join(kb_dir, entry)
|
439
|
+
File.directory?(path) && !entry.start_with?(".")
|
440
|
+
end.sort
|
441
|
+
end
|
442
|
+
|
443
|
+
def print_kbs_list(kb_folders)
|
444
|
+
Aircana.human_logger.info("Configured knowledge bases:")
|
445
|
+
kb_folders.each_with_index do |kb_name, index|
|
446
|
+
kb_type = get_kb_type(kb_name)
|
447
|
+
sources_count = get_sources_count(kb_name)
|
448
|
+
Aircana.human_logger.info(" #{index + 1}. #{kb_name} (#{kb_type}, #{sources_count} sources)")
|
449
|
+
end
|
450
|
+
Aircana.human_logger.info("\nTotal: #{kb_folders.length} knowledge bases")
|
451
|
+
end
|
452
|
+
|
453
|
+
def get_kb_type(kb_name)
|
454
|
+
Aircana::Contexts::Manifest.kb_type_from_manifest(kb_name) || "unknown"
|
455
|
+
end
|
456
|
+
|
457
|
+
def get_sources_count(kb_name)
|
458
|
+
sources = Aircana::Contexts::Manifest.sources_from_manifest(kb_name)
|
459
|
+
sources.size
|
460
|
+
rescue StandardError
|
461
|
+
0
|
462
|
+
end
|
463
|
+
|
464
|
+
def kb_exists?(kb_name)
|
465
|
+
kb_dir = File.join(Aircana.configuration.kb_knowledge_dir, kb_name)
|
466
|
+
Dir.exist?(kb_dir)
|
467
|
+
end
|
468
|
+
|
469
|
+
def valid_url?(url)
|
470
|
+
uri = URI.parse(url)
|
471
|
+
%w[http https].include?(uri.scheme) && !uri.host.nil?
|
472
|
+
rescue URI::InvalidURIError
|
473
|
+
false
|
474
|
+
end
|
475
|
+
|
476
|
+
def all_kbs
|
477
|
+
kb_dir = Aircana.configuration.kb_knowledge_dir
|
478
|
+
return [] unless Dir.exist?(kb_dir)
|
479
|
+
|
480
|
+
find_kb_folders(kb_dir)
|
481
|
+
end
|
482
|
+
|
483
|
+
# rubocop:disable Metrics/MethodLength
|
484
|
+
def refresh_single_kb(kb_name)
|
485
|
+
Aircana.human_logger.info "Refreshing KB '#{kb_name}'..."
|
486
|
+
|
487
|
+
begin
|
488
|
+
result = perform_manifest_aware_refresh(kb_name)
|
489
|
+
regenerate_skill_md(kb_name)
|
490
|
+
{
|
491
|
+
success: true,
|
492
|
+
pages_count: result[:pages_count],
|
493
|
+
sources: result[:sources]
|
494
|
+
}
|
495
|
+
rescue Aircana::Error => e
|
496
|
+
Aircana.human_logger.error "Failed to refresh KB '#{kb_name}': #{e.message}"
|
497
|
+
{
|
498
|
+
success: false,
|
499
|
+
pages_count: 0,
|
500
|
+
sources: [],
|
501
|
+
error: e.message
|
502
|
+
}
|
503
|
+
end
|
504
|
+
end
|
505
|
+
# rubocop:enable Metrics/MethodLength
|
506
|
+
|
507
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
508
|
+
def print_refresh_all_summary(results)
|
509
|
+
Aircana.human_logger.info ""
|
510
|
+
Aircana.human_logger.info "=== Refresh All Summary ==="
|
511
|
+
Aircana.human_logger.success "✓ Successful: #{results[:successful]}/#{results[:total]} KBs"
|
512
|
+
Aircana.human_logger.success "✓ Total pages refreshed: #{results[:total_pages]}"
|
513
|
+
|
514
|
+
if results[:skipped].positive?
|
515
|
+
Aircana.human_logger.info "⊘ Skipped: #{results[:skipped]} KB(s) (local knowledge base)"
|
516
|
+
end
|
517
|
+
|
518
|
+
if results[:failed].positive?
|
519
|
+
Aircana.human_logger.error "✗ Failed: #{results[:failed]} KBs"
|
520
|
+
Aircana.human_logger.info ""
|
521
|
+
Aircana.human_logger.info "Failed KBs:"
|
522
|
+
results[:failed_kbs].each do |failed_kb|
|
523
|
+
Aircana.human_logger.error " - #{failed_kb[:name]}: #{failed_kb[:error]}"
|
524
|
+
end
|
525
|
+
end
|
526
|
+
|
527
|
+
Aircana.human_logger.info ""
|
528
|
+
end
|
529
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
530
|
+
|
531
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
532
|
+
def ensure_remote_knowledge_refresh_hook
|
533
|
+
hooks_manifest = Aircana::HooksManifest.new(Aircana.configuration.plugin_root)
|
534
|
+
|
535
|
+
# Check if refresh hook already exists
|
536
|
+
current_hooks = hooks_manifest.read || {}
|
537
|
+
session_start_hooks = current_hooks["SessionStart"] || []
|
538
|
+
|
539
|
+
# Check if our refresh script already exists
|
540
|
+
refresh_hook_exists = session_start_hooks.any? do |hook_group|
|
541
|
+
hook_group["hooks"]&.any? { |h| h["command"]&.include?("refresh_remote_kbs.sh") }
|
542
|
+
end
|
543
|
+
|
544
|
+
return if refresh_hook_exists
|
545
|
+
|
546
|
+
# Generate the refresh script
|
547
|
+
generate_refresh_script
|
548
|
+
|
549
|
+
# Add hook to manifest
|
550
|
+
hook_entry = {
|
551
|
+
"type" => "command",
|
552
|
+
"command" => "${CLAUDE_PLUGIN_ROOT}/scripts/refresh_remote_kbs.sh"
|
553
|
+
}
|
554
|
+
|
555
|
+
hooks_manifest.add_hook(event: "SessionStart", hook_entry: hook_entry)
|
556
|
+
Aircana.human_logger.success "Added SessionStart hook to refresh remote knowledge bases"
|
557
|
+
end
|
558
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
559
|
+
|
560
|
+
# rubocop:disable Metrics/MethodLength
|
561
|
+
def generate_refresh_script
|
562
|
+
script_path = File.join(Aircana.configuration.scripts_dir, "refresh_remote_kbs.sh")
|
563
|
+
return if File.exist?(script_path)
|
564
|
+
|
565
|
+
script_content = <<~BASH
|
566
|
+
#!/bin/bash
|
567
|
+
# Auto-generated by Aircana
|
568
|
+
# Refreshes all remote knowledge bases from Confluence/web sources
|
569
|
+
|
570
|
+
cd "${CLAUDE_PLUGIN_ROOT}" || exit 1
|
571
|
+
|
572
|
+
# Only refresh if aircana is available
|
573
|
+
if ! command -v aircana &> /dev/null; then
|
574
|
+
echo "Aircana not found, skipping KB refresh"
|
575
|
+
exit 0
|
576
|
+
fi
|
577
|
+
|
578
|
+
# Refresh all remote KBs silently
|
579
|
+
aircana kb refresh-all 2>&1 | grep -E "(Successful|Failed|Error)" || true
|
580
|
+
BASH
|
581
|
+
|
582
|
+
FileUtils.mkdir_p(Aircana.configuration.scripts_dir)
|
583
|
+
File.write(script_path, script_content)
|
584
|
+
File.chmod(0o755, script_path)
|
585
|
+
end
|
586
|
+
# rubocop:enable Metrics/MethodLength
|
587
|
+
end
|
588
|
+
end
|
589
|
+
end
|
590
|
+
end
|
@@ -23,8 +23,7 @@ module Aircana
|
|
23
23
|
|
24
24
|
def command_groups
|
25
25
|
{
|
26
|
-
"
|
27
|
-
"Agent Management" => %w[agents],
|
26
|
+
"Knowledge Base Management" => %w[kb],
|
28
27
|
"Hook Management" => %w[hooks],
|
29
28
|
"System" => %w[generate init doctor dump-context]
|
30
29
|
}
|
@@ -52,7 +51,7 @@ module Aircana
|
|
52
51
|
end
|
53
52
|
|
54
53
|
def subcommand?(cmd_name)
|
55
|
-
%w[
|
54
|
+
%w[kb hooks].include?(cmd_name)
|
56
55
|
end
|
57
56
|
|
58
57
|
def print_subcommand_group(subcommand_name, cmd)
|
@@ -63,7 +62,9 @@ module Aircana
|
|
63
62
|
end
|
64
63
|
|
65
64
|
def get_subcommand_class(subcommand_name)
|
66
|
-
|
65
|
+
# Handle special cases like "kb" -> "KB"
|
66
|
+
prefix = subcommand_name == "kb" ? "KB" : subcommand_name.capitalize
|
67
|
+
class_name = "#{prefix}Subcommand"
|
67
68
|
return self.class.const_get(class_name) if self.class.const_defined?(class_name)
|
68
69
|
|
69
70
|
nil
|