aidp 0.16.0 → 0.17.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.
@@ -8,10 +8,10 @@ module Aidp
8
8
  # lookup, filtering, and management capabilities.
9
9
  #
10
10
  # Skills are loaded from:
11
- # 1. Built-in skills directory (project_root/skills/)
12
- # 2. Custom skills directory (.aidp/skills/)
11
+ # 1. Template skills directory (gem templates/skills/) - built-in templates
12
+ # 2. Project skills directory (.aidp/skills/) - project-specific skills
13
13
  #
14
- # Custom skills with matching IDs override built-in skills.
14
+ # Project skills with matching IDs override template skills.
15
15
  #
16
16
  # @example Basic usage
17
17
  # registry = Registry.new(project_dir: "/path/to/project")
@@ -39,8 +39,8 @@ module Aidp
39
39
  # Load skills from all search paths
40
40
  #
41
41
  # Skills are loaded in order:
42
- # 1. Built-in skills (project_root/skills/)
43
- # 2. Custom skills (.aidp/skills/) - override built-in
42
+ # 1. Template skills (gem templates/skills/) - built-in templates
43
+ # 2. Project skills (.aidp/skills/) - override templates
44
44
  #
45
45
  # @return [Integer] Number of skills loaded
46
46
  def load_skills
@@ -48,13 +48,13 @@ module Aidp
48
48
 
49
49
  @skills = {}
50
50
 
51
- # Load built-in skills first
52
- builtin_skills = load_from_path(builtin_skills_path)
53
- builtin_skills.each { |skill| register_skill(skill, source: :builtin) }
51
+ # Load template skills first
52
+ template_skills = load_from_path(template_skills_path)
53
+ template_skills.each { |skill| register_skill(skill, source: :template) }
54
54
 
55
- # Load custom skills (override built-in if IDs match)
56
- custom_skills = load_from_path(custom_skills_path)
57
- custom_skills.each { |skill| register_skill(skill, source: :custom) }
55
+ # Load project skills (override templates if IDs match)
56
+ project_skills = load_from_path(project_skills_path)
57
+ project_skills.each { |skill| register_skill(skill, source: :project) }
58
58
 
59
59
  @loaded = true
60
60
 
@@ -139,13 +139,13 @@ module Aidp
139
139
 
140
140
  # Get skill IDs grouped by source
141
141
  #
142
- # @return [Hash] Hash with :builtin and :custom arrays
142
+ # @return [Hash] Hash with :template and :project arrays
143
143
  def by_source
144
144
  load_skills unless loaded?
145
145
 
146
146
  {
147
- builtin: @skills.values.select { |s| builtin_skill?(s) }.map(&:id),
148
- custom: @skills.values.select { |s| custom_skill?(s) }.map(&:id)
147
+ template: @skills.values.select { |s| template_skill?(s) }.map(&:id),
148
+ project: @skills.values.select { |s| project_skill?(s) }.map(&:id)
149
149
  }
150
150
  end
151
151
 
@@ -186,34 +186,36 @@ module Aidp
186
186
  Loader.load_from_directory(path, provider: provider)
187
187
  end
188
188
 
189
- # Get built-in skills path
189
+ # Get template skills path (from gem)
190
190
  #
191
- # @return [String] Path to built-in skills directory
192
- def builtin_skills_path
193
- File.join(project_dir, "skills")
191
+ # @return [String] Path to template skills directory
192
+ def template_skills_path
193
+ # Get the gem root directory (go up from lib/aidp/skills/registry.rb)
194
+ gem_root = File.expand_path("../../../..", __dir__)
195
+ File.join(gem_root, "templates", "skills")
194
196
  end
195
197
 
196
- # Get custom skills path
198
+ # Get project skills path
197
199
  #
198
- # @return [String] Path to custom skills directory
199
- def custom_skills_path
200
+ # @return [String] Path to project-specific skills directory
201
+ def project_skills_path
200
202
  File.join(project_dir, ".aidp", "skills")
201
203
  end
202
204
 
203
- # Check if skill is from built-in directory
205
+ # Check if skill is from template directory
204
206
  #
205
207
  # @param skill [Skill] Skill to check
206
- # @return [Boolean] True if built-in
207
- def builtin_skill?(skill)
208
- skill.source_path.start_with?(builtin_skills_path)
208
+ # @return [Boolean] True if template
209
+ def template_skill?(skill)
210
+ skill.source_path.start_with?(template_skills_path)
209
211
  end
210
212
 
211
- # Check if skill is from custom directory
213
+ # Check if skill is from project directory
212
214
  #
213
215
  # @param skill [Skill] Skill to check
214
- # @return [Boolean] True if custom
215
- def custom_skill?(skill)
216
- skill.source_path.start_with?(custom_skills_path)
216
+ # @return [Boolean] True if project
217
+ def project_skill?(skill)
218
+ skill.source_path.start_with?(project_skills_path)
217
219
  end
218
220
  end
219
221
  end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Aidp
6
+ module Skills
7
+ # Routes file paths and tasks to appropriate skills based on routing rules
8
+ #
9
+ # The Router reads routing configuration from aidp.yml and matches:
10
+ # - File paths against glob patterns (path_rules)
11
+ # - Task descriptions against keywords (task_rules)
12
+ # - Combined path + task rules (combined_rules)
13
+ #
14
+ # @example Basic usage
15
+ # router = Router.new(project_dir: Dir.pwd)
16
+ # skill_id = router.route_by_path("app/controllers/users_controller.rb")
17
+ # # => "rails_expert"
18
+ #
19
+ # @example Task-based routing
20
+ # router = Router.new(project_dir: Dir.pwd)
21
+ # skill_id = router.route_by_task("Add API endpoint")
22
+ # # => "backend_developer"
23
+ #
24
+ # @example Combined routing
25
+ # router = Router.new(project_dir: Dir.pwd)
26
+ # skill_id = router.route(path: "lib/cli.rb", task: "Add command")
27
+ # # => "cli_expert"
28
+ class Router
29
+ attr_reader :config, :project_dir
30
+
31
+ # Initialize router with project directory
32
+ #
33
+ # @param project_dir [String] Path to project directory
34
+ def initialize(project_dir:)
35
+ @project_dir = project_dir
36
+ @config = load_config
37
+ end
38
+
39
+ # Route based on both path and task (highest priority)
40
+ #
41
+ # @param path [String, nil] File path to route
42
+ # @param task [String, nil] Task description to route
43
+ # @return [String, nil] Matched skill ID or nil
44
+ def route(path: nil, task: nil)
45
+ # Priority 1: Combined rules (path AND task)
46
+ if path && task
47
+ combined_match = match_combined_rules(path, task)
48
+ return combined_match if combined_match
49
+ end
50
+
51
+ # Priority 2: Path rules
52
+ path_match = route_by_path(path) if path
53
+ return path_match if path_match
54
+
55
+ # Priority 3: Task rules
56
+ task_match = route_by_task(task) if task
57
+ return task_match if task_match
58
+
59
+ # Priority 4: Default skill
60
+ config.dig("skills", "routing", "default")
61
+ end
62
+
63
+ # Route based on file path
64
+ #
65
+ # @param path [String] File path to match against patterns
66
+ # @return [String, nil] Matched skill ID or nil
67
+ def route_by_path(path)
68
+ return nil unless path
69
+
70
+ path_rules = config.dig("skills", "routing", "path_rules") || {}
71
+
72
+ path_rules.each do |skill_id, patterns|
73
+ patterns = [patterns] unless patterns.is_a?(Array)
74
+ patterns.each do |pattern|
75
+ return skill_id if File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
76
+ end
77
+ end
78
+
79
+ nil
80
+ end
81
+
82
+ # Route based on task description
83
+ #
84
+ # @param task [String] Task description to match against keywords
85
+ # @return [String, nil] Matched skill ID or nil
86
+ def route_by_task(task)
87
+ return nil unless task
88
+
89
+ task_rules = config.dig("skills", "routing", "task_rules") || {}
90
+ task_lower = task.downcase
91
+
92
+ task_rules.each do |skill_id, keywords|
93
+ keywords = [keywords] unless keywords.is_a?(Array)
94
+ keywords.each do |keyword|
95
+ return skill_id if task_lower.include?(keyword.downcase)
96
+ end
97
+ end
98
+
99
+ nil
100
+ end
101
+
102
+ # Check if routing is enabled
103
+ #
104
+ # @return [Boolean] true if routing is configured
105
+ def routing_enabled?
106
+ config.dig("skills", "routing", "enabled") == true
107
+ end
108
+
109
+ # Get default skill
110
+ #
111
+ # @return [String, nil] Default skill ID or nil
112
+ def default_skill
113
+ config.dig("skills", "routing", "default")
114
+ end
115
+
116
+ # Get all routing rules
117
+ #
118
+ # @return [Hash] Hash containing path_rules, task_rules, and combined_rules
119
+ def rules
120
+ {
121
+ path_rules: config.dig("skills", "routing", "path_rules") || {},
122
+ task_rules: config.dig("skills", "routing", "task_rules") || {},
123
+ combined_rules: config.dig("skills", "routing", "combined_rules") || {}
124
+ }
125
+ end
126
+
127
+ private
128
+
129
+ # Match combined rules (path AND task must both match)
130
+ #
131
+ # @param path [String] File path
132
+ # @param task [String] Task description
133
+ # @return [String, nil] Matched skill ID or nil
134
+ def match_combined_rules(path, task)
135
+ combined_rules = config.dig("skills", "routing", "combined_rules") || {}
136
+ task_lower = task.downcase
137
+
138
+ combined_rules.each do |skill_id, rule|
139
+ path_patterns = rule["paths"] || []
140
+ task_keywords = rule["tasks"] || []
141
+
142
+ path_patterns = [path_patterns] unless path_patterns.is_a?(Array)
143
+ task_keywords = [task_keywords] unless task_keywords.is_a?(Array)
144
+
145
+ # Check if path matches any pattern
146
+ path_match = path_patterns.any? do |pattern|
147
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
148
+ end
149
+
150
+ # Check if task matches any keyword
151
+ task_match = task_keywords.any? do |keyword|
152
+ task_lower.include?(keyword.downcase)
153
+ end
154
+
155
+ return skill_id if path_match && task_match
156
+ end
157
+
158
+ nil
159
+ end
160
+
161
+ # Load routing configuration from aidp.yml
162
+ #
163
+ # @return [Hash] Configuration hash
164
+ def load_config
165
+ config_path = File.join(project_dir, ".aidp", "aidp.yml")
166
+
167
+ if File.exist?(config_path)
168
+ YAML.safe_load_file(config_path, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
169
+ else
170
+ {}
171
+ end
172
+ rescue => e
173
+ Aidp.log_error("skills", "Failed to load routing config", error: e.message)
174
+ {}
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../skill"
4
+
5
+ module Aidp
6
+ module Skills
7
+ module Wizard
8
+ # Builds Skill objects from wizard responses
9
+ #
10
+ # Takes user input from the wizard and constructs a valid Skill object.
11
+ # Handles template inheritance by merging metadata and content.
12
+ #
13
+ # @example Building a new skill
14
+ # builder = Builder.new
15
+ # responses = { id: "my_skill", name: "My Skill", ... }
16
+ # skill = builder.build(responses)
17
+ #
18
+ # @example Building from a template
19
+ # builder = Builder.new(base_skill: template)
20
+ # responses = { id: "my_skill", ... }
21
+ # skill = builder.build(responses) # Inherits from template
22
+ class Builder
23
+ attr_reader :base_skill
24
+
25
+ # Initialize builder
26
+ #
27
+ # @param base_skill [Skill, nil] Optional base skill for inheritance
28
+ def initialize(base_skill: nil)
29
+ @base_skill = base_skill
30
+ end
31
+
32
+ # Build a Skill from wizard responses
33
+ #
34
+ # @param responses [Hash] Wizard responses
35
+ # @option responses [String] :id Skill ID
36
+ # @option responses [String] :name Skill name
37
+ # @option responses [String] :description Description
38
+ # @option responses [String] :version Version (default: "1.0.0")
39
+ # @option responses [Array<String>] :expertise Expertise areas
40
+ # @option responses [Array<String>] :keywords Keywords
41
+ # @option responses [Array<String>] :when_to_use When to use
42
+ # @option responses [Array<String>] :when_not_to_use When not to use
43
+ # @option responses [Array<String>] :compatible_providers Compatible providers
44
+ # @option responses [String] :content Markdown content
45
+ # @return [Skill] Built skill
46
+ def build(responses)
47
+ # Merge with base skill if provided
48
+ merged = if base_skill
49
+ merge_with_base(responses)
50
+ else
51
+ responses
52
+ end
53
+
54
+ # Create skill with merged attributes
55
+ # Note: source_path will be set by Writer when saved
56
+ Skill.new(
57
+ id: merged[:id],
58
+ name: merged[:name],
59
+ description: merged[:description],
60
+ version: merged[:version] || "1.0.0",
61
+ expertise: Array(merged[:expertise]),
62
+ keywords: Array(merged[:keywords]),
63
+ when_to_use: Array(merged[:when_to_use]),
64
+ when_not_to_use: Array(merged[:when_not_to_use]),
65
+ compatible_providers: Array(merged[:compatible_providers]),
66
+ content: merged[:content],
67
+ source_path: merged[:source_path] || "<pending>"
68
+ )
69
+ end
70
+
71
+ # Generate YAML frontmatter + content for writing
72
+ #
73
+ # @param skill [Skill] Skill to serialize
74
+ # @return [String] Complete SKILL.md content
75
+ def to_skill_md(skill)
76
+ frontmatter = build_frontmatter(skill)
77
+ "#{frontmatter}\n#{skill.content}"
78
+ end
79
+
80
+ private
81
+
82
+ # Merge responses with base skill
83
+ #
84
+ # @param responses [Hash] User responses
85
+ # @return [Hash] Merged attributes
86
+ def merge_with_base(responses)
87
+ {
88
+ id: responses[:id],
89
+ name: responses[:name] || base_skill.name,
90
+ description: responses[:description] || base_skill.description,
91
+ version: responses[:version] || "1.0.0",
92
+ expertise: merge_arrays(base_skill.expertise, responses[:expertise]),
93
+ keywords: merge_arrays(base_skill.keywords, responses[:keywords]),
94
+ when_to_use: merge_arrays(base_skill.when_to_use, responses[:when_to_use]),
95
+ when_not_to_use: merge_arrays(base_skill.when_not_to_use, responses[:when_not_to_use]),
96
+ compatible_providers: responses[:compatible_providers] || base_skill.compatible_providers,
97
+ content: responses[:content] || base_skill.content,
98
+ source_path: responses[:source_path]
99
+ }
100
+ end
101
+
102
+ # Merge arrays (base + new, deduplicated)
103
+ #
104
+ # @param base [Array] Base array
105
+ # @param new_items [Array, nil] New items
106
+ # @return [Array] Merged and deduplicated array
107
+ def merge_arrays(base, new_items)
108
+ return base if new_items.nil? || new_items.empty?
109
+ (base + Array(new_items)).uniq
110
+ end
111
+
112
+ # Build YAML frontmatter
113
+ #
114
+ # @param skill [Skill] Skill object
115
+ # @return [String] YAML frontmatter block
116
+ def build_frontmatter(skill)
117
+ data = {
118
+ "id" => skill.id,
119
+ "name" => skill.name,
120
+ "description" => skill.description,
121
+ "version" => skill.version
122
+ }
123
+
124
+ # Add optional arrays if not empty
125
+ data["expertise"] = skill.expertise unless skill.expertise.empty?
126
+ data["keywords"] = skill.keywords unless skill.keywords.empty?
127
+ data["when_to_use"] = skill.when_to_use unless skill.when_to_use.empty?
128
+ data["when_not_to_use"] = skill.when_not_to_use unless skill.when_not_to_use.empty?
129
+ data["compatible_providers"] = skill.compatible_providers unless skill.compatible_providers.empty?
130
+
131
+ # Generate YAML
132
+ yaml_content = data.to_yaml
133
+ # Remove the leading "---\n" that to_yaml adds, we'll add our own delimiters
134
+ yaml_content = yaml_content.sub(/\A---\n/, "")
135
+
136
+ "---\n#{yaml_content}---"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_library"
4
+ require_relative "prompter"
5
+ require_relative "builder"
6
+ require_relative "writer"
7
+
8
+ module Aidp
9
+ module Skills
10
+ module Wizard
11
+ # Controller orchestrating the skill creation wizard
12
+ #
13
+ # Coordinates the wizard flow: template selection, prompting, building,
14
+ # preview, and writing the skill.
15
+ #
16
+ # @example Creating a new skill
17
+ # controller = Controller.new(project_dir: "/path/to/project")
18
+ # controller.run
19
+ #
20
+ # @example With options
21
+ # controller = Controller.new(
22
+ # project_dir: "/path/to/project",
23
+ # options: { id: "my_skill", dry_run: true }
24
+ # )
25
+ # controller.run
26
+ class Controller
27
+ attr_reader :project_dir, :options, :template_library, :prompter, :writer
28
+
29
+ # Initialize controller
30
+ #
31
+ # @param project_dir [String] Root directory of the project
32
+ # @param options [Hash] Wizard options
33
+ # @option options [String] :id Pre-filled skill ID
34
+ # @option options [String] :name Pre-filled skill name
35
+ # @option options [Boolean] :dry_run Don't write files
36
+ # @option options [Boolean] :minimal Skip optional sections
37
+ # @option options [Boolean] :open_editor Open in $EDITOR (future)
38
+ def initialize(project_dir:, options: {})
39
+ @project_dir = project_dir
40
+ @options = options
41
+ @template_library = TemplateLibrary.new(project_dir: project_dir)
42
+ @prompter = Prompter.new
43
+ @writer = Writer.new(project_dir: project_dir)
44
+ end
45
+
46
+ # Run the wizard
47
+ #
48
+ # @return [String, nil] Path to created skill file, or nil if cancelled/dry-run
49
+ def run
50
+ Aidp.log_info("wizard", "Starting skill creation wizard", options: options)
51
+
52
+ # Gather user responses
53
+ responses = prompter.gather_responses(template_library, options: options)
54
+
55
+ # Build skill from responses
56
+ base_skill = responses.delete(:base_skill)
57
+ builder = Builder.new(base_skill: base_skill)
58
+ skill = builder.build(responses)
59
+
60
+ # Generate SKILL.md content
61
+ skill_md_content = builder.to_skill_md(skill)
62
+
63
+ # Preview
64
+ show_preview(skill, skill_md_content) unless options[:minimal]
65
+
66
+ # Confirm
67
+ unless options[:dry_run] || options[:yes] || confirm_save(skill)
68
+ prompter.prompt.warn("Cancelled")
69
+ return nil
70
+ end
71
+
72
+ # Write to disk
73
+ path = writer.write(
74
+ skill,
75
+ content: skill_md_content,
76
+ dry_run: options[:dry_run],
77
+ backup: true
78
+ )
79
+
80
+ # Show success message
81
+ show_success(skill, path) unless options[:dry_run]
82
+
83
+ path
84
+ rescue Interrupt
85
+ prompter.prompt.warn("\nWizard cancelled")
86
+ nil
87
+ rescue => e
88
+ Aidp.log_error("wizard", "Wizard failed", error: e.message, backtrace: e.backtrace.first(5))
89
+ prompter.prompt.error("Error: #{e.message}")
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ # Show preview of the skill
96
+ #
97
+ # @param skill [Skill] Built skill
98
+ # @param content [String] SKILL.md content
99
+ def show_preview(skill, content)
100
+ prompter.prompt.say("\n" + "=" * 60)
101
+ prompter.prompt.say("Skill Preview: #{skill.id} v#{skill.version}")
102
+ prompter.prompt.say("=" * 60 + "\n")
103
+
104
+ # Show first 20 lines of content
105
+ lines = content.lines
106
+ preview_lines = lines.first(20)
107
+ prompter.prompt.say(preview_lines.join)
108
+
109
+ if lines.size > 20
110
+ prompter.prompt.say("\n... (#{lines.size - 20} more lines)")
111
+ end
112
+
113
+ prompter.prompt.say("\n" + "=" * 60 + "\n")
114
+ end
115
+
116
+ # Confirm before saving
117
+ #
118
+ # @param skill [Skill] Skill to save
119
+ # @return [Boolean] True if user confirms
120
+ def confirm_save(skill)
121
+ if writer.exists?(skill.id)
122
+ prompter.prompt.warn("Warning: Skill '#{skill.id}' already exists and will be overwritten (backup will be created)")
123
+ end
124
+
125
+ prompter.prompt.yes?("Save this skill?")
126
+ end
127
+
128
+ # Show success message
129
+ #
130
+ # @param skill [Skill] Created skill
131
+ # @param path [String] Path to file
132
+ def show_success(skill, path)
133
+ prompter.prompt.say("\n✅ Skill created successfully!\n")
134
+ prompter.prompt.say(" ID: #{skill.id}")
135
+ prompter.prompt.say(" Version: #{skill.version}")
136
+ prompter.prompt.say(" File: #{path}")
137
+ prompter.prompt.say("\nNext steps:")
138
+ prompter.prompt.say(" • Review: aidp skill preview #{skill.id}")
139
+ prompter.prompt.say(" • Edit: aidp skill edit #{skill.id}")
140
+ prompter.prompt.say(" • Use: Reference in your workflow steps\n")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end