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
@@ -1,733 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "json"
|
4
|
-
require "tty-prompt"
|
5
|
-
require_relative "../../generators/agents_generator"
|
6
|
-
require_relative "../../contexts/manifest"
|
7
|
-
require_relative "../../contexts/web"
|
8
|
-
|
9
|
-
module Aircana
|
10
|
-
module CLI
|
11
|
-
module Agents # rubocop:disable Metrics/ModuleLength
|
12
|
-
SUPPORTED_CLAUDE_MODELS = %w[sonnet haiku inherit].freeze
|
13
|
-
SUPPORTED_CLAUDE_COLORS = %w[red blue green yellow purple orange pink cyan].freeze
|
14
|
-
|
15
|
-
class << self # rubocop:disable Metrics/ClassLength
|
16
|
-
def refresh(agent)
|
17
|
-
normalized_agent = normalize_string(agent)
|
18
|
-
|
19
|
-
# Check if this is a local knowledge base agent
|
20
|
-
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_agent)
|
21
|
-
if kb_type == "local"
|
22
|
-
Aircana.human_logger.info "⊘ Skipping #{normalized_agent} (local knowledge base - no refresh needed)"
|
23
|
-
return
|
24
|
-
end
|
25
|
-
|
26
|
-
perform_manifest_aware_refresh(normalized_agent)
|
27
|
-
rescue Aircana::Error => e
|
28
|
-
handle_refresh_error(normalized_agent, e)
|
29
|
-
end
|
30
|
-
|
31
|
-
def create
|
32
|
-
prompt = TTY::Prompt.new
|
33
|
-
|
34
|
-
agent_name = prompt.ask("Agent name:")
|
35
|
-
short_description = prompt.ask("Briefly describe what your agent does:")
|
36
|
-
model = prompt.select("Select a model for your agent:", SUPPORTED_CLAUDE_MODELS)
|
37
|
-
color = prompt.select("Select a color for your agent:", SUPPORTED_CLAUDE_COLORS)
|
38
|
-
|
39
|
-
# Prompt for knowledge base type
|
40
|
-
kb_type = prompt.select("Knowledge base type:", [
|
41
|
-
{
|
42
|
-
name: "Remote - Fetched from Confluence/web, stored in ~/.claude/agents/, " \
|
43
|
-
"requires refresh",
|
44
|
-
value: "remote"
|
45
|
-
},
|
46
|
-
{
|
47
|
-
name: "Local - Version controlled in plugin, auto-synced to ~/.claude/agents/ " \
|
48
|
-
"on session start",
|
49
|
-
value: "local"
|
50
|
-
}
|
51
|
-
])
|
52
|
-
|
53
|
-
description = description_from_claude(short_description)
|
54
|
-
normalized_agent_name = normalize_string(agent_name)
|
55
|
-
|
56
|
-
file = Generators::AgentsGenerator.new(
|
57
|
-
agent_name: normalized_agent_name,
|
58
|
-
description:,
|
59
|
-
short_description:,
|
60
|
-
model: normalize_string(model),
|
61
|
-
color: normalize_string(color),
|
62
|
-
kb_type:
|
63
|
-
).generate
|
64
|
-
|
65
|
-
Aircana.human_logger.success "Agent created at #{file}"
|
66
|
-
|
67
|
-
# If local kb_type, ensure SessionStart hook is installed
|
68
|
-
ensure_local_knowledge_sync_hook if kb_type == "local"
|
69
|
-
|
70
|
-
# Prompt for knowledge fetching
|
71
|
-
prompt_for_knowledge_fetch(prompt, normalized_agent_name, kb_type)
|
72
|
-
|
73
|
-
# Prompt for web URL fetching
|
74
|
-
prompt_for_url_fetch(prompt, normalized_agent_name, kb_type)
|
75
|
-
|
76
|
-
# Prompt for agent file review
|
77
|
-
prompt_for_agent_review(prompt, file)
|
78
|
-
|
79
|
-
Aircana.human_logger.success "Agent '#{agent_name}' setup complete!"
|
80
|
-
end
|
81
|
-
|
82
|
-
def list
|
83
|
-
agent_dir = Aircana.configuration.agent_knowledge_dir
|
84
|
-
return print_no_agents_message unless Dir.exist?(agent_dir)
|
85
|
-
|
86
|
-
agent_folders = find_agent_folders(agent_dir)
|
87
|
-
return print_no_agents_message if agent_folders.empty?
|
88
|
-
|
89
|
-
print_agents_list(agent_folders)
|
90
|
-
end
|
91
|
-
|
92
|
-
def add_url(agent, url)
|
93
|
-
normalized_agent = normalize_string(agent)
|
94
|
-
|
95
|
-
unless agent_exists?(normalized_agent)
|
96
|
-
Aircana.human_logger.error "Agent '#{agent}' not found. Use 'aircana agents list' to see available agents."
|
97
|
-
exit 1
|
98
|
-
end
|
99
|
-
|
100
|
-
# Get kb_type from manifest to know where to store
|
101
|
-
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(normalized_agent)
|
102
|
-
|
103
|
-
web = Aircana::Contexts::Web.new
|
104
|
-
result = web.fetch_url_for(agent: normalized_agent, url: url, kb_type: kb_type)
|
105
|
-
|
106
|
-
if result
|
107
|
-
# Update manifest with the new URL
|
108
|
-
existing_sources = Aircana::Contexts::Manifest.sources_from_manifest(normalized_agent)
|
109
|
-
web_sources = existing_sources.select { |s| s["type"] == "web" }
|
110
|
-
other_sources = existing_sources.reject { |s| s["type"] == "web" }
|
111
|
-
|
112
|
-
if web_sources.any?
|
113
|
-
# Add to existing web source
|
114
|
-
web_sources.first["urls"] << result
|
115
|
-
else
|
116
|
-
# Create new web source
|
117
|
-
web_sources = [{ "type" => "web", "urls" => [result] }]
|
118
|
-
end
|
119
|
-
|
120
|
-
all_sources = other_sources + web_sources
|
121
|
-
Aircana::Contexts::Manifest.update_manifest(normalized_agent, all_sources)
|
122
|
-
|
123
|
-
Aircana.human_logger.success "Successfully added URL to agent '#{agent}'"
|
124
|
-
else
|
125
|
-
Aircana.human_logger.error "Failed to fetch URL: #{url}"
|
126
|
-
exit 1
|
127
|
-
end
|
128
|
-
rescue Aircana::Error => e
|
129
|
-
Aircana.human_logger.error "Failed to add URL: #{e.message}"
|
130
|
-
exit 1
|
131
|
-
end
|
132
|
-
|
133
|
-
def refresh_all
|
134
|
-
agent_names = all_agents
|
135
|
-
|
136
|
-
if agent_names.empty?
|
137
|
-
Aircana.human_logger.info "No agents found to refresh."
|
138
|
-
return
|
139
|
-
end
|
140
|
-
|
141
|
-
Aircana.human_logger.info "Starting refresh for #{agent_names.size} agent(s)..."
|
142
|
-
|
143
|
-
results = {
|
144
|
-
total: agent_names.size,
|
145
|
-
successful: 0,
|
146
|
-
failed: 0,
|
147
|
-
skipped: 0,
|
148
|
-
total_pages: 0,
|
149
|
-
failed_agents: [],
|
150
|
-
skipped_agents: []
|
151
|
-
}
|
152
|
-
|
153
|
-
agent_names.each do |agent_name|
|
154
|
-
# Check if this is a local knowledge base agent
|
155
|
-
kb_type = Aircana::Contexts::Manifest.kb_type_from_manifest(agent_name)
|
156
|
-
if kb_type == "local"
|
157
|
-
Aircana.human_logger.info "⊘ Skipping #{agent_name} (local knowledge base)"
|
158
|
-
results[:skipped] += 1
|
159
|
-
results[:skipped_agents] << agent_name
|
160
|
-
next
|
161
|
-
end
|
162
|
-
|
163
|
-
result = refresh_single_agent(agent_name)
|
164
|
-
if result[:success]
|
165
|
-
results[:successful] += 1
|
166
|
-
results[:total_pages] += result[:pages_count]
|
167
|
-
else
|
168
|
-
results[:failed] += 1
|
169
|
-
results[:failed_agents] << { name: agent_name, error: result[:error] }
|
170
|
-
end
|
171
|
-
end
|
172
|
-
|
173
|
-
print_refresh_all_summary(results)
|
174
|
-
end
|
175
|
-
|
176
|
-
def migrate_to_local
|
177
|
-
agent_names = all_agents
|
178
|
-
|
179
|
-
if agent_names.empty?
|
180
|
-
Aircana.human_logger.info "No agents found to migrate."
|
181
|
-
return
|
182
|
-
end
|
183
|
-
|
184
|
-
# Filter to only remote agents
|
185
|
-
remote_agents = agent_names.select do |agent_name|
|
186
|
-
Aircana::Contexts::Manifest.kb_type_from_manifest(agent_name) == "remote"
|
187
|
-
end
|
188
|
-
|
189
|
-
if remote_agents.empty?
|
190
|
-
Aircana.human_logger.info "No remote agents found. All agents are already using local knowledge bases."
|
191
|
-
return
|
192
|
-
end
|
193
|
-
|
194
|
-
Aircana.human_logger.warn "⚠️ This will migrate #{remote_agents.size} agent(s) to local knowledge bases."
|
195
|
-
Aircana.human_logger.warn "Knowledge will be version-controlled in agents/<agent>/knowledge/"
|
196
|
-
Aircana.human_logger.warn "and auto-synced to ~/.claude/agents/ on session start"
|
197
|
-
Aircana.human_logger.info ""
|
198
|
-
|
199
|
-
results = {
|
200
|
-
total: remote_agents.size,
|
201
|
-
successful: 0,
|
202
|
-
failed: 0,
|
203
|
-
migrated_agents: [],
|
204
|
-
failed_agents: []
|
205
|
-
}
|
206
|
-
|
207
|
-
remote_agents.each do |agent_name|
|
208
|
-
result = migrate_single_agent(agent_name)
|
209
|
-
if result[:success]
|
210
|
-
results[:successful] += 1
|
211
|
-
results[:migrated_agents] << agent_name
|
212
|
-
else
|
213
|
-
results[:failed] += 1
|
214
|
-
results[:failed_agents] << { name: agent_name, error: result[:error] }
|
215
|
-
end
|
216
|
-
end
|
217
|
-
|
218
|
-
print_migration_summary(results)
|
219
|
-
end
|
220
|
-
|
221
|
-
private
|
222
|
-
|
223
|
-
def perform_refresh(normalized_agent, kb_type = "remote")
|
224
|
-
confluence = Aircana::Contexts::Confluence.new
|
225
|
-
result = confluence.fetch_pages_for(agent: normalized_agent, kb_type: kb_type)
|
226
|
-
|
227
|
-
log_refresh_result(normalized_agent, result[:pages_count])
|
228
|
-
result
|
229
|
-
end
|
230
|
-
|
231
|
-
def log_refresh_result(normalized_agent, pages_count)
|
232
|
-
if pages_count.positive?
|
233
|
-
Aircana.human_logger.success "Successfully refreshed #{pages_count} pages for agent '#{normalized_agent}'"
|
234
|
-
else
|
235
|
-
log_no_pages_found(normalized_agent)
|
236
|
-
end
|
237
|
-
end
|
238
|
-
|
239
|
-
def perform_manifest_aware_refresh(normalized_agent)
|
240
|
-
total_pages = 0
|
241
|
-
all_sources = []
|
242
|
-
|
243
|
-
# Try manifest-based refresh first
|
244
|
-
if Aircana::Contexts::Manifest.manifest_exists?(normalized_agent)
|
245
|
-
Aircana.human_logger.info "Refreshing from knowledge manifest..."
|
246
|
-
|
247
|
-
# Refresh Confluence sources
|
248
|
-
confluence = Aircana::Contexts::Confluence.new
|
249
|
-
confluence_result = confluence.refresh_from_manifest(agent: normalized_agent)
|
250
|
-
total_pages += confluence_result[:pages_count]
|
251
|
-
all_sources.concat(confluence_result[:sources])
|
252
|
-
|
253
|
-
# Refresh web sources
|
254
|
-
web = Aircana::Contexts::Web.new
|
255
|
-
web_result = web.refresh_web_sources(agent: normalized_agent)
|
256
|
-
total_pages += web_result[:pages_count]
|
257
|
-
all_sources.concat(web_result[:sources])
|
258
|
-
else
|
259
|
-
Aircana.human_logger.info "No manifest found, falling back to label-based search..."
|
260
|
-
confluence = Aircana::Contexts::Confluence.new
|
261
|
-
confluence_result = confluence.fetch_pages_for(agent: normalized_agent)
|
262
|
-
total_pages += confluence_result[:pages_count]
|
263
|
-
all_sources.concat(confluence_result[:sources])
|
264
|
-
end
|
265
|
-
|
266
|
-
# Update manifest with all sources combined
|
267
|
-
Aircana::Contexts::Manifest.update_manifest(normalized_agent, all_sources) if all_sources.any?
|
268
|
-
|
269
|
-
log_refresh_result(normalized_agent, total_pages)
|
270
|
-
{ pages_count: total_pages, sources: all_sources }
|
271
|
-
end
|
272
|
-
|
273
|
-
def ensure_gitignore_entry(kb_type = "remote")
|
274
|
-
gitignore_path = gitignore_file_path
|
275
|
-
|
276
|
-
if kb_type == "local"
|
277
|
-
# For local agents, ensure version-controlled knowledge is not ignored
|
278
|
-
ensure_local_knowledge_not_ignored(gitignore_path)
|
279
|
-
else
|
280
|
-
# For remote agents, add ignore pattern for .claude/agents/*/knowledge/
|
281
|
-
ensure_remote_knowledge_ignored(gitignore_path)
|
282
|
-
end
|
283
|
-
rescue StandardError => e
|
284
|
-
Aircana.human_logger.warn "Could not update .gitignore: #{e.message}"
|
285
|
-
end
|
286
|
-
|
287
|
-
def ensure_remote_knowledge_ignored(gitignore_path)
|
288
|
-
pattern = remote_knowledge_pattern
|
289
|
-
return if gitignore_has_pattern?(gitignore_path, pattern)
|
290
|
-
|
291
|
-
append_to_gitignore(gitignore_path, pattern)
|
292
|
-
Aircana.human_logger.success "Added remote knowledge directories to .gitignore"
|
293
|
-
end
|
294
|
-
|
295
|
-
def ensure_local_knowledge_not_ignored(gitignore_path)
|
296
|
-
negation_pattern = local_knowledge_negation_pattern
|
297
|
-
return if gitignore_has_pattern?(gitignore_path, negation_pattern)
|
298
|
-
|
299
|
-
# Add comment and negation pattern
|
300
|
-
comment = "# Local agent knowledge IS version controlled (don't ignore)"
|
301
|
-
content_to_append = "\n#{comment}\n#{negation_pattern}\n"
|
302
|
-
|
303
|
-
existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
304
|
-
needs_newline = !existing_content.empty? && !existing_content.end_with?("\n")
|
305
|
-
content_to_append = "\n#{content_to_append}" if needs_newline
|
306
|
-
|
307
|
-
File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
|
308
|
-
Aircana.human_logger.success "Added local knowledge negation to .gitignore"
|
309
|
-
end
|
310
|
-
|
311
|
-
def gitignore_file_path
|
312
|
-
File.join(Aircana.configuration.project_dir, ".gitignore")
|
313
|
-
end
|
314
|
-
|
315
|
-
def remote_knowledge_pattern
|
316
|
-
".claude/agents/*/knowledge/"
|
317
|
-
end
|
318
|
-
|
319
|
-
def local_knowledge_negation_pattern
|
320
|
-
"!agents/*/knowledge/"
|
321
|
-
end
|
322
|
-
|
323
|
-
def gitignore_has_pattern?(gitignore_path, pattern)
|
324
|
-
return false unless File.exist?(gitignore_path)
|
325
|
-
|
326
|
-
content = File.read(gitignore_path)
|
327
|
-
if content.lines.any? { |line| line.strip == pattern }
|
328
|
-
Aircana.human_logger.info "Pattern '#{pattern}' already in .gitignore"
|
329
|
-
true
|
330
|
-
else
|
331
|
-
false
|
332
|
-
end
|
333
|
-
end
|
334
|
-
|
335
|
-
def append_to_gitignore(gitignore_path, pattern)
|
336
|
-
existing_content = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
|
337
|
-
content_to_append = existing_content.empty? || existing_content.end_with?("\n") ? "" : "\n"
|
338
|
-
content_to_append += "#{pattern}\n"
|
339
|
-
|
340
|
-
File.open(gitignore_path, "a") { |f| f.write(content_to_append) }
|
341
|
-
end
|
342
|
-
|
343
|
-
def log_no_pages_found(normalized_agent)
|
344
|
-
Aircana.human_logger.info "No pages found for agent '#{normalized_agent}'. " \
|
345
|
-
"Make sure pages are labeled with '#{normalized_agent}' in Confluence."
|
346
|
-
end
|
347
|
-
|
348
|
-
def handle_refresh_error(normalized_agent, error)
|
349
|
-
Aircana.human_logger.error "Failed to refresh agent '#{normalized_agent}': #{error.message}"
|
350
|
-
exit 1
|
351
|
-
end
|
352
|
-
|
353
|
-
def normalize_string(string)
|
354
|
-
string.strip.downcase.gsub(" ", "-")
|
355
|
-
end
|
356
|
-
|
357
|
-
def description_from_claude(description)
|
358
|
-
prompt = build_agent_description_prompt(description)
|
359
|
-
claude_client = Aircana::LLM::ClaudeClient.new
|
360
|
-
claude_client.prompt(prompt)
|
361
|
-
end
|
362
|
-
|
363
|
-
def build_agent_description_prompt(description)
|
364
|
-
<<~PROMPT
|
365
|
-
Create a concise Claude Code agent description file (without frontmatter)
|
366
|
-
for an agent that is described as: #{description}.
|
367
|
-
|
368
|
-
The agent should be specialized and focused on its domain knowledge.
|
369
|
-
Include instructions that the agent should primarily rely on information
|
370
|
-
from its knowledge base rather than general knowledge when answering questions
|
371
|
-
within its domain.
|
372
|
-
|
373
|
-
Print the output to STDOUT only, without any additional commentary.
|
374
|
-
|
375
|
-
The description should be 2-3 sentences. Most of the agent's context comes from
|
376
|
-
its knowledge base
|
377
|
-
PROMPT
|
378
|
-
end
|
379
|
-
|
380
|
-
def prompt_for_knowledge_fetch(prompt, normalized_agent_name, kb_type)
|
381
|
-
return unless confluence_configured?
|
382
|
-
|
383
|
-
if prompt.yes?("Would you like to fetch knowledge for this agent from Confluence now?")
|
384
|
-
Aircana.human_logger.info "Fetching knowledge from Confluence..."
|
385
|
-
result = perform_refresh(normalized_agent_name, kb_type)
|
386
|
-
ensure_gitignore_entry(kb_type) if result[:pages_count]&.positive?
|
387
|
-
else
|
388
|
-
refresh_message = if kb_type == "local"
|
389
|
-
"fetch knowledge"
|
390
|
-
else
|
391
|
-
"run 'aircana agents refresh #{normalized_agent_name}'"
|
392
|
-
end
|
393
|
-
Aircana.human_logger.info(
|
394
|
-
"Skipping knowledge fetch. You can #{refresh_message} later."
|
395
|
-
)
|
396
|
-
end
|
397
|
-
rescue Aircana::Error => e
|
398
|
-
Aircana.human_logger.warn "Failed to fetch knowledge: #{e.message}"
|
399
|
-
refresh_message = if kb_type == "local"
|
400
|
-
"fetch knowledge"
|
401
|
-
else
|
402
|
-
"try again later with 'aircana agents refresh #{normalized_agent_name}'"
|
403
|
-
end
|
404
|
-
Aircana.human_logger.info "You can #{refresh_message}"
|
405
|
-
end
|
406
|
-
|
407
|
-
def prompt_for_url_fetch(prompt, normalized_agent_name, kb_type)
|
408
|
-
return unless prompt.yes?("Would you like to add web URLs for this agent's knowledge base?")
|
409
|
-
|
410
|
-
urls = []
|
411
|
-
loop do
|
412
|
-
url = prompt.ask("Enter URL (or press Enter to finish):")
|
413
|
-
break if url.nil? || url.strip.empty?
|
414
|
-
|
415
|
-
url = url.strip
|
416
|
-
if valid_url?(url)
|
417
|
-
urls << url
|
418
|
-
else
|
419
|
-
Aircana.human_logger.warn "Invalid URL format: #{url}. Please enter a valid HTTP or HTTPS URL."
|
420
|
-
end
|
421
|
-
end
|
422
|
-
|
423
|
-
return if urls.empty?
|
424
|
-
|
425
|
-
begin
|
426
|
-
Aircana.human_logger.info "Fetching #{urls.size} URL(s)..."
|
427
|
-
web = Aircana::Contexts::Web.new
|
428
|
-
result = web.fetch_urls_for(agent: normalized_agent_name, urls: urls, kb_type: kb_type)
|
429
|
-
|
430
|
-
if result[:pages_count].positive?
|
431
|
-
Aircana.human_logger.success "Successfully fetched #{result[:pages_count]} URL(s)"
|
432
|
-
ensure_gitignore_entry(kb_type)
|
433
|
-
else
|
434
|
-
Aircana.human_logger.warn "No URLs were successfully fetched"
|
435
|
-
end
|
436
|
-
rescue Aircana::Error => e
|
437
|
-
Aircana.human_logger.warn "Failed to fetch URLs: #{e.message}"
|
438
|
-
Aircana.human_logger.info(
|
439
|
-
"You can add URLs later with 'aircana agents add-url #{normalized_agent_name} <URL>'"
|
440
|
-
)
|
441
|
-
end
|
442
|
-
end
|
443
|
-
|
444
|
-
def prompt_for_agent_review(prompt, file_path)
|
445
|
-
Aircana.human_logger.info "Agent file created at: #{file_path}"
|
446
|
-
|
447
|
-
return unless prompt.yes?("Would you like to review and edit the agent file?")
|
448
|
-
|
449
|
-
open_file_in_editor(file_path)
|
450
|
-
end
|
451
|
-
|
452
|
-
def confluence_configured?
|
453
|
-
config = Aircana.configuration
|
454
|
-
|
455
|
-
base_url_present = !config.confluence_base_url.nil? && !config.confluence_base_url.empty?
|
456
|
-
username_present = !config.confluence_username.nil? && !config.confluence_username.empty?
|
457
|
-
token_present = !config.confluence_api_token.nil? && !config.confluence_api_token.empty?
|
458
|
-
|
459
|
-
base_url_present && username_present && token_present
|
460
|
-
end
|
461
|
-
|
462
|
-
def open_file_in_editor(file_path)
|
463
|
-
editor = ENV["EDITOR"] || find_available_editor
|
464
|
-
|
465
|
-
if editor
|
466
|
-
Aircana.human_logger.info "Opening #{file_path} in #{editor}..."
|
467
|
-
system("#{editor} '#{file_path}'")
|
468
|
-
else
|
469
|
-
Aircana.human_logger.warn "No editor found. Please edit #{file_path} manually."
|
470
|
-
end
|
471
|
-
end
|
472
|
-
|
473
|
-
def print_no_agents_message
|
474
|
-
Aircana.human_logger.info("No agents configured yet.")
|
475
|
-
end
|
476
|
-
|
477
|
-
def find_agent_folders(agent_dir)
|
478
|
-
Dir.entries(agent_dir).select do |entry|
|
479
|
-
path = File.join(agent_dir, entry)
|
480
|
-
File.directory?(path) && !entry.start_with?(".")
|
481
|
-
end
|
482
|
-
end
|
483
|
-
|
484
|
-
def print_agents_list(agent_folders)
|
485
|
-
Aircana.human_logger.info("Configured agents:")
|
486
|
-
agent_folders.each_with_index do |agent_name, index|
|
487
|
-
description = get_agent_description(agent_name)
|
488
|
-
Aircana.human_logger.info(" #{index + 1}. #{agent_name} - #{description}")
|
489
|
-
end
|
490
|
-
Aircana.human_logger.info("\nTotal: #{agent_folders.length} agents")
|
491
|
-
end
|
492
|
-
|
493
|
-
def get_agent_description(agent_name)
|
494
|
-
agent_config_path = File.join(
|
495
|
-
Aircana.configuration.agent_knowledge_dir,
|
496
|
-
agent_name,
|
497
|
-
"agent.json"
|
498
|
-
)
|
499
|
-
return "Configuration incomplete" unless File.exist?(agent_config_path)
|
500
|
-
|
501
|
-
config = JSON.parse(File.read(agent_config_path))
|
502
|
-
config["description"] || "No description available"
|
503
|
-
end
|
504
|
-
|
505
|
-
def agent_exists?(agent_name)
|
506
|
-
agent_dir = File.join(Aircana.configuration.agent_knowledge_dir, agent_name)
|
507
|
-
Dir.exist?(agent_dir)
|
508
|
-
end
|
509
|
-
|
510
|
-
def valid_url?(url)
|
511
|
-
uri = URI.parse(url)
|
512
|
-
%w[http https].include?(uri.scheme) && !uri.host.nil?
|
513
|
-
rescue URI::InvalidURIError
|
514
|
-
false
|
515
|
-
end
|
516
|
-
|
517
|
-
def find_available_editor
|
518
|
-
%w[code subl atom nano vim vi].find { |cmd| system("which #{cmd} > /dev/null 2>&1") }
|
519
|
-
end
|
520
|
-
|
521
|
-
def all_agents
|
522
|
-
agent_dir = Aircana.configuration.agent_knowledge_dir
|
523
|
-
return [] unless Dir.exist?(agent_dir)
|
524
|
-
|
525
|
-
find_agent_folders(agent_dir)
|
526
|
-
end
|
527
|
-
|
528
|
-
def refresh_single_agent(agent_name)
|
529
|
-
Aircana.human_logger.info "Refreshing agent '#{agent_name}'..."
|
530
|
-
|
531
|
-
begin
|
532
|
-
result = perform_manifest_aware_refresh(agent_name)
|
533
|
-
{
|
534
|
-
success: true,
|
535
|
-
pages_count: result[:pages_count],
|
536
|
-
sources: result[:sources]
|
537
|
-
}
|
538
|
-
rescue Aircana::Error => e
|
539
|
-
Aircana.human_logger.error "Failed to refresh agent '#{agent_name}': #{e.message}"
|
540
|
-
{
|
541
|
-
success: false,
|
542
|
-
pages_count: 0,
|
543
|
-
sources: [],
|
544
|
-
error: e.message
|
545
|
-
}
|
546
|
-
end
|
547
|
-
end
|
548
|
-
|
549
|
-
def print_refresh_all_summary(results)
|
550
|
-
Aircana.human_logger.info ""
|
551
|
-
Aircana.human_logger.info "=== Refresh All Summary ==="
|
552
|
-
Aircana.human_logger.success "✓ Successful: #{results[:successful]}/#{results[:total]} agents"
|
553
|
-
Aircana.human_logger.success "✓ Total pages refreshed: #{results[:total_pages]}"
|
554
|
-
|
555
|
-
if results[:skipped].positive?
|
556
|
-
Aircana.human_logger.info "⊘ Skipped: #{results[:skipped]} agent(s) (local knowledge base)"
|
557
|
-
end
|
558
|
-
|
559
|
-
if results[:failed].positive?
|
560
|
-
Aircana.human_logger.error "✗ Failed: #{results[:failed]} agents"
|
561
|
-
Aircana.human_logger.info ""
|
562
|
-
Aircana.human_logger.info "Failed agents:"
|
563
|
-
results[:failed_agents].each do |failed_agent|
|
564
|
-
Aircana.human_logger.error " - #{failed_agent[:name]}: #{failed_agent[:error]}"
|
565
|
-
end
|
566
|
-
end
|
567
|
-
|
568
|
-
Aircana.human_logger.info ""
|
569
|
-
end
|
570
|
-
|
571
|
-
def migrate_single_agent(agent_name)
|
572
|
-
Aircana.human_logger.info "Migrating agent '#{agent_name}'..."
|
573
|
-
|
574
|
-
begin
|
575
|
-
# Step 1: Refresh knowledge from sources to ensure we have latest
|
576
|
-
Aircana.human_logger.info " Refreshing knowledge from sources..."
|
577
|
-
perform_manifest_aware_refresh(agent_name)
|
578
|
-
|
579
|
-
# Step 2: Copy knowledge files from global to local directory
|
580
|
-
Aircana.human_logger.info " Copying knowledge files to local directory..."
|
581
|
-
copy_knowledge_files(agent_name)
|
582
|
-
|
583
|
-
# Step 3: Update manifest kb_type to "local"
|
584
|
-
Aircana.human_logger.info " Updating manifest..."
|
585
|
-
sources = Aircana::Contexts::Manifest.sources_from_manifest(agent_name)
|
586
|
-
Aircana::Contexts::Manifest.update_manifest(agent_name, sources, kb_type: "local")
|
587
|
-
|
588
|
-
# Step 4: Regenerate agent file with new kb_type
|
589
|
-
Aircana.human_logger.info " Regenerating agent file..."
|
590
|
-
regenerate_agent_file(agent_name)
|
591
|
-
|
592
|
-
# Step 5: Ensure SessionStart hook is installed (only install once for all local agents)
|
593
|
-
Aircana.human_logger.info " Ensuring SessionStart hook is installed..."
|
594
|
-
ensure_local_knowledge_sync_hook
|
595
|
-
|
596
|
-
Aircana.human_logger.success "✓ Successfully migrated '#{agent_name}'"
|
597
|
-
{ success: true }
|
598
|
-
rescue StandardError => e
|
599
|
-
Aircana.human_logger.error "✗ Failed to migrate agent '#{agent_name}': #{e.message}"
|
600
|
-
{ success: false, error: e.message }
|
601
|
-
end
|
602
|
-
end
|
603
|
-
|
604
|
-
def copy_knowledge_files(agent_name)
|
605
|
-
config = Aircana.configuration
|
606
|
-
source_dir = config.global_agent_knowledge_path(agent_name)
|
607
|
-
dest_dir = config.local_agent_knowledge_path(agent_name)
|
608
|
-
|
609
|
-
unless Dir.exist?(source_dir)
|
610
|
-
Aircana.human_logger.warn " No knowledge files found at #{source_dir}"
|
611
|
-
return
|
612
|
-
end
|
613
|
-
|
614
|
-
FileUtils.mkdir_p(dest_dir)
|
615
|
-
|
616
|
-
# Copy all markdown files
|
617
|
-
Dir.glob(File.join(source_dir, "*.md")).each do |file|
|
618
|
-
filename = File.basename(file)
|
619
|
-
dest_file = File.join(dest_dir, filename)
|
620
|
-
FileUtils.cp(file, dest_file)
|
621
|
-
Aircana.human_logger.info " Copied #{filename}"
|
622
|
-
end
|
623
|
-
end
|
624
|
-
|
625
|
-
def regenerate_agent_file(agent_name)
|
626
|
-
# Read existing agent file to get metadata
|
627
|
-
agent_file_path = File.join(Aircana.configuration.agents_dir, "#{agent_name}.md")
|
628
|
-
unless File.exist?(agent_file_path)
|
629
|
-
Aircana.human_logger.warn " Agent file not found at #{agent_file_path}, skipping regeneration"
|
630
|
-
return
|
631
|
-
end
|
632
|
-
|
633
|
-
# Parse frontmatter to get agent metadata
|
634
|
-
content = File.read(agent_file_path)
|
635
|
-
frontmatter_match = content.match(/^---\n(.*?)\n---\n(.*)$/m)
|
636
|
-
return unless frontmatter_match
|
637
|
-
|
638
|
-
frontmatter = frontmatter_match[1]
|
639
|
-
description = frontmatter_match[2]
|
640
|
-
|
641
|
-
# Parse YAML-like frontmatter
|
642
|
-
metadata = {}
|
643
|
-
frontmatter.lines.each do |line|
|
644
|
-
key, value = line.strip.split(":", 2)
|
645
|
-
metadata[key.strip] = value&.strip
|
646
|
-
end
|
647
|
-
|
648
|
-
# Generate new agent file with kb_type="local"
|
649
|
-
Generators::AgentsGenerator.new(
|
650
|
-
agent_name: agent_name,
|
651
|
-
description: description,
|
652
|
-
short_description: metadata["description"],
|
653
|
-
model: metadata["model"],
|
654
|
-
color: metadata["color"],
|
655
|
-
kb_type: "local"
|
656
|
-
).generate
|
657
|
-
end
|
658
|
-
|
659
|
-
def print_migration_summary(results)
|
660
|
-
Aircana.human_logger.info ""
|
661
|
-
Aircana.human_logger.info "=== Migration Summary ==="
|
662
|
-
Aircana.human_logger.success "✓ Successfully migrated: #{results[:successful]}/#{results[:total]} agents"
|
663
|
-
|
664
|
-
if results[:successful].positive?
|
665
|
-
Aircana.human_logger.info ""
|
666
|
-
Aircana.human_logger.info "Migrated agents:"
|
667
|
-
results[:migrated_agents].each do |agent_name|
|
668
|
-
Aircana.human_logger.success " ✓ #{agent_name}"
|
669
|
-
end
|
670
|
-
end
|
671
|
-
|
672
|
-
if results[:failed].positive?
|
673
|
-
Aircana.human_logger.error "✗ Failed: #{results[:failed]} agents"
|
674
|
-
Aircana.human_logger.info ""
|
675
|
-
Aircana.human_logger.info "Failed agents:"
|
676
|
-
results[:failed_agents].each do |failed_agent|
|
677
|
-
Aircana.human_logger.error " - #{failed_agent[:name]}: #{failed_agent[:error]}"
|
678
|
-
end
|
679
|
-
end
|
680
|
-
|
681
|
-
if results[:successful].positive?
|
682
|
-
Aircana.human_logger.info ""
|
683
|
-
Aircana.human_logger.warn "⚠️ Knowledge is now version-controlled in agents/<agent>/knowledge/"
|
684
|
-
Aircana.human_logger.info "A SessionStart hook has been added to auto-sync knowledge to ~/.claude/agents/"
|
685
|
-
Aircana.human_logger.info "Review and commit these changes to your repository."
|
686
|
-
end
|
687
|
-
|
688
|
-
Aircana.human_logger.info ""
|
689
|
-
end
|
690
|
-
|
691
|
-
def ensure_local_knowledge_sync_hook
|
692
|
-
hooks_manifest = Aircana::HooksManifest.new(Aircana.configuration.plugin_root)
|
693
|
-
|
694
|
-
# Check if sync hook already exists
|
695
|
-
current_hooks = hooks_manifest.read || {}
|
696
|
-
session_start_hooks = current_hooks["SessionStart"] || []
|
697
|
-
|
698
|
-
# Check if our sync script already exists
|
699
|
-
sync_hook_exists = session_start_hooks.any? do |hook_group|
|
700
|
-
hook_group["hooks"]&.any? { |h| h["command"]&.include?("sync_local_knowledge.sh") }
|
701
|
-
end
|
702
|
-
|
703
|
-
return if sync_hook_exists
|
704
|
-
|
705
|
-
# Generate the sync script
|
706
|
-
generate_sync_script
|
707
|
-
|
708
|
-
# Add hook to manifest
|
709
|
-
hook_entry = {
|
710
|
-
"type" => "command",
|
711
|
-
"command" => "${CLAUDE_PLUGIN_ROOT}/scripts/sync_local_knowledge.sh"
|
712
|
-
}
|
713
|
-
|
714
|
-
hooks_manifest.add_hook(event: "SessionStart", hook_entry: hook_entry)
|
715
|
-
Aircana.human_logger.success "Added SessionStart hook to sync local knowledge bases"
|
716
|
-
end
|
717
|
-
|
718
|
-
def generate_sync_script
|
719
|
-
script_path = File.join(Aircana.configuration.scripts_dir, "sync_local_knowledge.sh")
|
720
|
-
return if File.exist?(script_path)
|
721
|
-
|
722
|
-
template_path = File.join(File.dirname(__FILE__), "..", "..", "templates", "hooks",
|
723
|
-
"sync_local_knowledge.erb")
|
724
|
-
template_content = File.read(template_path)
|
725
|
-
|
726
|
-
FileUtils.mkdir_p(Aircana.configuration.scripts_dir)
|
727
|
-
File.write(script_path, ERB.new(template_content).result)
|
728
|
-
File.chmod(0o755, script_path)
|
729
|
-
end
|
730
|
-
end
|
731
|
-
end
|
732
|
-
end
|
733
|
-
end
|