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.
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require_relative "builder"
5
+
6
+ module Aidp
7
+ module Skills
8
+ module Wizard
9
+ # Generates and displays diffs between skills
10
+ #
11
+ # Shows differences between an original skill and a modified version,
12
+ # or between a project skill and its template.
13
+ #
14
+ # @example Showing a diff
15
+ # differ = Differ.new
16
+ # diff = differ.diff(original_skill, modified_skill)
17
+ # differ.display(diff)
18
+ class Differ
19
+ attr_reader :pastel
20
+
21
+ def initialize
22
+ @pastel = Pastel.new
23
+ end
24
+
25
+ # Generate a diff between two skills
26
+ #
27
+ # @param original [Skill, String] Original skill or content
28
+ # @param modified [Skill, String] Modified skill or content
29
+ # @return [Hash] Diff information
30
+ def diff(original, modified)
31
+ original_content = skill_to_content(original)
32
+ modified_content = skill_to_content(modified)
33
+
34
+ {
35
+ original: original_content,
36
+ modified: modified_content,
37
+ lines: generate_line_diff(original_content, modified_content),
38
+ has_changes: original_content != modified_content
39
+ }
40
+ end
41
+
42
+ # Display a diff to the terminal
43
+ #
44
+ # @param diff [Hash] Diff information from #diff
45
+ # @param output [IO] Output stream (default: $stdout)
46
+ def display(diff, output: $stdout)
47
+ unless diff[:has_changes]
48
+ output.puts pastel.dim("No differences found")
49
+ return
50
+ end
51
+
52
+ output.puts pastel.bold("\n" + "=" * 60)
53
+ output.puts pastel.bold("Skill Diff")
54
+ output.puts pastel.bold("=" * 60)
55
+
56
+ diff[:lines].each do |line_info|
57
+ case line_info[:type]
58
+ when :context
59
+ output.puts pastel.dim(" #{line_info[:line]}")
60
+ when :add
61
+ output.puts pastel.green("+ #{line_info[:line]}")
62
+ when :remove
63
+ output.puts pastel.red("- #{line_info[:line]}")
64
+ end
65
+ end
66
+
67
+ output.puts pastel.bold("=" * 60 + "\n")
68
+ end
69
+
70
+ # Generate a unified diff string
71
+ #
72
+ # @param original [Skill, String] Original skill or content
73
+ # @param modified [Skill, String] Modified skill or content
74
+ # @return [String] Unified diff format
75
+ def unified_diff(original, modified)
76
+ original_content = skill_to_content(original)
77
+ modified_content = skill_to_content(modified)
78
+
79
+ original_lines = original_content.lines
80
+ modified_lines = modified_content.lines
81
+
82
+ diff_lines = []
83
+ diff_lines << "--- original"
84
+ diff_lines << "+++ modified"
85
+
86
+ # Simple line-by-line diff (not optimal but functional)
87
+ max_lines = [original_lines.size, modified_lines.size].max
88
+
89
+ (0...max_lines).each do |i|
90
+ orig_line = original_lines[i]
91
+ mod_line = modified_lines[i]
92
+
93
+ if orig_line && !mod_line
94
+ diff_lines << "-#{orig_line}"
95
+ elsif !orig_line && mod_line
96
+ diff_lines << "+#{mod_line}"
97
+ elsif orig_line != mod_line
98
+ diff_lines << "-#{orig_line}"
99
+ diff_lines << "+#{mod_line}"
100
+ else
101
+ diff_lines << " #{orig_line}"
102
+ end
103
+ end
104
+
105
+ diff_lines.join
106
+ end
107
+
108
+ # Compare a project skill with its template
109
+ #
110
+ # @param project_skill [Skill] Project skill
111
+ # @param template_skill [Skill] Template skill
112
+ # @return [Hash] Comparison information
113
+ def compare_with_template(project_skill, template_skill)
114
+ {
115
+ skill_id: project_skill.id,
116
+ overrides: detect_overrides(project_skill, template_skill),
117
+ additions: detect_additions(project_skill, template_skill),
118
+ diff: diff(template_skill, project_skill)
119
+ }
120
+ end
121
+
122
+ private
123
+
124
+ # Convert skill or string to content
125
+ #
126
+ # @param skill_or_content [Skill, String] Skill object or content string
127
+ # @return [String] Content string
128
+ def skill_to_content(skill_or_content)
129
+ if skill_or_content.is_a?(String)
130
+ skill_or_content
131
+ elsif skill_or_content.respond_to?(:content)
132
+ # Build full SKILL.md content
133
+ builder = Builder.new
134
+ builder.to_skill_md(skill_or_content)
135
+ else
136
+ skill_or_content.to_s
137
+ end
138
+ end
139
+
140
+ # Generate line-by-line diff
141
+ #
142
+ # @param original [String] Original content
143
+ # @param modified [String] Modified content
144
+ # @return [Array<Hash>] Array of line diff information
145
+ def generate_line_diff(original, modified)
146
+ original_lines = original.lines.map(&:chomp)
147
+ modified_lines = modified.lines.map(&:chomp)
148
+
149
+ lines = []
150
+ i = 0
151
+ j = 0
152
+
153
+ while i < original_lines.size || j < modified_lines.size
154
+ if i >= original_lines.size
155
+ # Only modified lines remain
156
+ lines << {type: :add, line: modified_lines[j]}
157
+ j += 1
158
+ elsif j >= modified_lines.size
159
+ # Only original lines remain
160
+ lines << {type: :remove, line: original_lines[i]}
161
+ i += 1
162
+ elsif original_lines[i] == modified_lines[j]
163
+ # Lines match
164
+ lines << {type: :context, line: original_lines[i]}
165
+ i += 1
166
+ j += 1
167
+ else
168
+ # Lines differ - show removal then addition
169
+ lines << {type: :remove, line: original_lines[i]}
170
+ lines << {type: :add, line: modified_lines[j]}
171
+ i += 1
172
+ j += 1
173
+ end
174
+ end
175
+
176
+ lines
177
+ end
178
+
179
+ # Detect field overrides between project and template
180
+ #
181
+ # @param project_skill [Skill] Project skill
182
+ # @param template_skill [Skill] Template skill
183
+ # @return [Hash] Hash of overridden fields
184
+ def detect_overrides(project_skill, template_skill)
185
+ overrides = {}
186
+
187
+ # Check metadata fields
188
+ if project_skill.name != template_skill.name
189
+ overrides[:name] = {template: template_skill.name, project: project_skill.name}
190
+ end
191
+
192
+ if project_skill.description != template_skill.description
193
+ overrides[:description] = {template: template_skill.description, project: project_skill.description}
194
+ end
195
+
196
+ if project_skill.version != template_skill.version
197
+ overrides[:version] = {template: template_skill.version, project: project_skill.version}
198
+ end
199
+
200
+ if project_skill.content != template_skill.content
201
+ overrides[:content] = {template: "...", project: "..."}
202
+ end
203
+
204
+ overrides
205
+ end
206
+
207
+ # Detect additions in project skill compared to template
208
+ #
209
+ # @param project_skill [Skill] Project skill
210
+ # @param template_skill [Skill] Template skill
211
+ # @return [Hash] Hash of added items
212
+ def detect_additions(project_skill, template_skill)
213
+ additions = {}
214
+
215
+ # Check for added expertise
216
+ new_expertise = project_skill.expertise - template_skill.expertise
217
+ additions[:expertise] = new_expertise if new_expertise.any?
218
+
219
+ # Check for added keywords
220
+ new_keywords = project_skill.keywords - template_skill.keywords
221
+ additions[:keywords] = new_keywords if new_keywords.any?
222
+
223
+ # Check for added when_to_use
224
+ new_when_to_use = project_skill.when_to_use - template_skill.when_to_use
225
+ additions[:when_to_use] = new_when_to_use if new_when_to_use.any?
226
+
227
+ additions
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module Aidp
6
+ module Skills
7
+ module Wizard
8
+ # Interactive prompter for the skill wizard
9
+ #
10
+ # Uses TTY::Prompt to gather user input through guided questions.
11
+ #
12
+ # @example Basic usage
13
+ # prompter = Prompter.new
14
+ # responses = prompter.gather_responses(template_library)
15
+ class Prompter
16
+ attr_reader :prompt
17
+
18
+ def initialize
19
+ @prompt = TTY::Prompt.new
20
+ end
21
+
22
+ # Gather all responses for creating a skill
23
+ #
24
+ # @param template_library [TemplateLibrary] Library for template selection
25
+ # @param options [Hash] Options for the wizard
26
+ # @option options [String] :id Pre-filled skill ID
27
+ # @option options [String] :name Pre-filled skill name
28
+ # @option options [Boolean] :minimal Skip optional sections
29
+ # @option options [String] :from_template Template ID to inherit from
30
+ # @option options [String] :clone Skill ID to clone
31
+ # @return [Hash] Complete set of responses
32
+ def gather_responses(template_library, options: {})
33
+ responses = {}
34
+
35
+ # Step 1: Template selection
36
+ if options[:from_template]
37
+ responses[:base_skill] = template_library.find(options[:from_template])
38
+ unless responses[:base_skill]
39
+ raise Aidp::Errors::ValidationError, "Template not found: #{options[:from_template]}"
40
+ end
41
+ elsif options[:clone]
42
+ responses[:base_skill] = template_library.find(options[:clone])
43
+ unless responses[:base_skill]
44
+ raise Aidp::Errors::ValidationError, "Skill not found: #{options[:clone]}"
45
+ end
46
+ elsif !options[:minimal]
47
+ responses[:base_skill] = prompt_template_selection(template_library)
48
+ end
49
+
50
+ # Step 2: Identity & Metadata
51
+ responses.merge!(prompt_identity(options))
52
+
53
+ # Step 3: Expertise & Keywords
54
+ responses.merge!(prompt_expertise) unless options[:minimal]
55
+
56
+ # Step 4: When to use
57
+ responses.merge!(prompt_when_to_use) unless options[:minimal]
58
+
59
+ # Step 5: Compatible providers
60
+ responses[:compatible_providers] = prompt_providers unless options[:minimal]
61
+
62
+ # Step 6: Content
63
+ responses[:content] = prompt_content(responses[:name], responses[:base_skill])
64
+
65
+ responses
66
+ end
67
+
68
+ private
69
+
70
+ # Prompt for template selection
71
+ #
72
+ # @param template_library [TemplateLibrary] Available templates
73
+ # @return [Skill, nil] Selected base skill or nil
74
+ def prompt_template_selection(template_library)
75
+ prompt.say("\n" + "=" * 60)
76
+ prompt.say("Create New Skill")
77
+ prompt.say("=" * 60 + "\n")
78
+
79
+ choice = prompt.select("How would you like to create your skill?") do |menu|
80
+ menu.choice "Start from scratch", :from_scratch
81
+ menu.choice "Inherit from a template", :inherit
82
+ menu.choice "Clone an existing skill", :clone
83
+ end
84
+
85
+ case choice
86
+ when :from_scratch
87
+ nil
88
+ when :inherit, :clone
89
+ select_base_skill(template_library)
90
+ end
91
+ end
92
+
93
+ # Select a base skill from templates
94
+ #
95
+ # @param template_library [TemplateLibrary] Available templates
96
+ # @return [Skill, nil] Selected skill
97
+ def select_base_skill(template_library)
98
+ skills = template_library.skill_list
99
+
100
+ if skills.empty?
101
+ prompt.warn("No templates available")
102
+ return nil
103
+ end
104
+
105
+ choices = skills.map do |skill_info|
106
+ source_label = (skill_info[:source] == :template) ? "(template)" : "(project)"
107
+ {
108
+ name: "#{skill_info[:name]} #{source_label} - #{skill_info[:description]}",
109
+ value: skill_info[:id]
110
+ }
111
+ end
112
+
113
+ selected_id = prompt.select("Select a base skill:", choices, per_page: 10)
114
+ template_library.find(selected_id)
115
+ end
116
+
117
+ # Prompt for identity and metadata
118
+ #
119
+ # @param options [Hash] Pre-filled values
120
+ # @return [Hash] Identity responses
121
+ def prompt_identity(options)
122
+ responses = {}
123
+
124
+ # Name
125
+ default_name = options[:name]
126
+ responses[:name] = prompt.ask("Skill Name:", default: default_name) do |q|
127
+ q.required true
128
+ q.modify :strip
129
+ end
130
+
131
+ # ID (auto-suggest from name)
132
+ suggested_id = options[:id] || slugify(responses[:name])
133
+ responses[:id] = prompt.ask("Skill ID:", default: suggested_id) do |q|
134
+ q.required true
135
+ q.modify :strip, :down
136
+ q.validate(/\A[a-z0-9_]+\z/, "ID must be lowercase alphanumeric with underscores only")
137
+ end
138
+
139
+ # Description
140
+ responses[:description] = prompt.ask("Description (one-line summary):") do |q|
141
+ q.required true
142
+ q.modify :strip
143
+ end
144
+
145
+ # Version
146
+ responses[:version] = prompt.ask("Version:", default: "1.0.0") do |q|
147
+ q.validate(/\A\d+\.\d+\.\d+\z/, "Must be semantic version (X.Y.Z)")
148
+ end
149
+
150
+ responses
151
+ end
152
+
153
+ # Prompt for expertise and keywords
154
+ #
155
+ # @return [Hash] Expertise responses
156
+ def prompt_expertise
157
+ responses = {}
158
+
159
+ # Expertise areas
160
+ prompt.say("\nExpertise Areas:")
161
+ prompt.say("(Enter expertise areas, one per line. Leave blank and press Enter when done)")
162
+
163
+ expertise = []
164
+ loop do
165
+ area = prompt.ask(" Area #{expertise.size + 1}:", required: false) do |q|
166
+ q.modify :strip
167
+ end
168
+ break if area.nil? || area.empty?
169
+ expertise << area
170
+ end
171
+ responses[:expertise] = expertise
172
+
173
+ # Keywords
174
+ keywords_input = prompt.ask("\nKeywords (comma-separated):", required: false) do |q|
175
+ q.modify :strip
176
+ end
177
+ responses[:keywords] = keywords_input ? keywords_input.split(",").map(&:strip).reject(&:empty?) : []
178
+
179
+ responses
180
+ end
181
+
182
+ # Prompt for when to use guidance
183
+ #
184
+ # @return [Hash] When to use responses
185
+ def prompt_when_to_use
186
+ responses = {}
187
+
188
+ # When to use
189
+ prompt.say("\nWhen to Use This Skill:")
190
+ prompt.say("(Enter use cases, one per line. Leave blank and press Enter when done)")
191
+
192
+ when_to_use = []
193
+ loop do
194
+ use_case = prompt.ask(" Use case #{when_to_use.size + 1}:", required: false) do |q|
195
+ q.modify :strip
196
+ end
197
+ break if use_case.nil? || use_case.empty?
198
+ when_to_use << use_case
199
+ end
200
+ responses[:when_to_use] = when_to_use
201
+
202
+ # When NOT to use
203
+ prompt.say("\nWhen NOT to Use This Skill:")
204
+ prompt.say("(Leave blank and press Enter when done)")
205
+
206
+ when_not_to_use = []
207
+ loop do
208
+ use_case = prompt.ask(" Avoid when #{when_not_to_use.size + 1}:", required: false) do |q|
209
+ q.modify :strip
210
+ end
211
+ break if use_case.nil? || use_case.empty?
212
+ when_not_to_use << use_case
213
+ end
214
+ responses[:when_not_to_use] = when_not_to_use
215
+
216
+ responses
217
+ end
218
+
219
+ # Prompt for compatible providers
220
+ #
221
+ # @return [Array<String>] Selected providers
222
+ def prompt_providers
223
+ choices = [
224
+ {name: "All providers (default)", value: []},
225
+ {name: "Anthropic only", value: ["anthropic"]},
226
+ {name: "OpenAI only", value: ["openai"]},
227
+ {name: "Codex only", value: ["codex"]},
228
+ {name: "Cursor only", value: ["cursor"]},
229
+ {name: "Custom selection", value: :custom}
230
+ ]
231
+
232
+ selection = prompt.select("\nCompatible AI Providers:", choices)
233
+
234
+ if selection == :custom
235
+ prompt.multi_select("Select providers:") do |menu|
236
+ menu.choice "Anthropic", "anthropic"
237
+ menu.choice "OpenAI", "openai"
238
+ menu.choice "Codex", "codex"
239
+ menu.choice "Cursor", "cursor"
240
+ end
241
+ else
242
+ selection
243
+ end
244
+ end
245
+
246
+ # Prompt for skill content
247
+ #
248
+ # @param skill_name [String] Name of the skill
249
+ # @param base_skill [Skill, nil] Base skill if inheriting
250
+ # @return [String] Markdown content
251
+ def prompt_content(skill_name, base_skill)
252
+ if base_skill
253
+ prompt.say("\nContent will be inherited from: #{base_skill.name}")
254
+ if prompt.yes?("Would you like to customize the content?")
255
+ prompt_custom_content(skill_name)
256
+ else
257
+ base_skill.content
258
+ end
259
+ else
260
+ prompt_custom_content(skill_name)
261
+ end
262
+ end
263
+
264
+ # Prompt for custom content
265
+ #
266
+ # @param skill_name [String] Name of the skill
267
+ # @return [String] Markdown content
268
+ def prompt_custom_content(skill_name)
269
+ prompt.say("\nContent:")
270
+ lines = prompt.multiline("Enter the skill content (markdown):")
271
+
272
+ if lines.empty?
273
+ # Provide a minimal template
274
+ generate_default_content(skill_name)
275
+ else
276
+ lines.join("\n")
277
+ end
278
+ end
279
+
280
+ # Generate default content template
281
+ #
282
+ # @param skill_name [String] Name of the skill
283
+ # @return [String] Default markdown content
284
+ def generate_default_content(skill_name)
285
+ <<~MARKDOWN
286
+ # #{skill_name}
287
+
288
+ You are a **#{skill_name}**, an expert in [describe expertise area].
289
+
290
+ ## Your Core Capabilities
291
+
292
+ - [Capability 1]
293
+ - [Capability 2]
294
+ - [Capability 3]
295
+
296
+ ## Your Approach
297
+
298
+ [Describe your philosophy and approach to tasks]
299
+ MARKDOWN
300
+ end
301
+
302
+ # Convert a name to a slug (for ID suggestion)
303
+ #
304
+ # @param name [String] Human-readable name
305
+ # @return [String] Slugified ID
306
+ def slugify(name)
307
+ name.to_s
308
+ .downcase
309
+ .gsub(/[^a-z0-9\s_-]/, "")
310
+ .gsub(/\s+/, "_")
311
+ .gsub(/-+/, "_").squeeze("_")
312
+ .gsub(/\A_|_\z/, "")
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loader"
4
+
5
+ module Aidp
6
+ module Skills
7
+ module Wizard
8
+ # Manages skill templates for the wizard
9
+ #
10
+ # Loads and provides access to template skills from the gem's templates/skills/ directory
11
+ # and existing project skills from .aidp/skills/ for cloning.
12
+ #
13
+ # @example Loading templates
14
+ # library = TemplateLibrary.new(project_dir: "/path/to/project")
15
+ # templates = library.templates
16
+ # template = library.find("base_developer")
17
+ class TemplateLibrary
18
+ attr_reader :project_dir
19
+
20
+ # Initialize template library
21
+ #
22
+ # @param project_dir [String] Root directory of the project
23
+ def initialize(project_dir:)
24
+ @project_dir = project_dir
25
+ @templates = nil
26
+ @project_skills = nil
27
+ end
28
+
29
+ # Get all available templates
30
+ #
31
+ # @return [Array<Skill>] Array of template skills
32
+ def templates
33
+ load_templates unless loaded?
34
+ @templates
35
+ end
36
+
37
+ # Get all project skills (for cloning)
38
+ #
39
+ # @return [Array<Skill>] Array of project-specific skills
40
+ def project_skills
41
+ load_project_skills unless project_skills_loaded?
42
+ @project_skills
43
+ end
44
+
45
+ # Get all skills (templates + project skills)
46
+ #
47
+ # @return [Array<Skill>] Combined array of all skills
48
+ def all
49
+ templates + project_skills
50
+ end
51
+
52
+ # Find a template or project skill by ID
53
+ #
54
+ # @param skill_id [String] Skill identifier
55
+ # @return [Skill, nil] Found skill or nil
56
+ def find(skill_id)
57
+ all.find { |skill| skill.id == skill_id.to_s }
58
+ end
59
+
60
+ # Check if a skill ID exists
61
+ #
62
+ # @param skill_id [String] Skill identifier
63
+ # @return [Boolean] True if skill exists
64
+ def exists?(skill_id)
65
+ !find(skill_id).nil?
66
+ end
67
+
68
+ # Get template names for display
69
+ #
70
+ # @return [Array<Hash>] Array of {id, name, description} hashes
71
+ def template_list
72
+ templates.map do |skill|
73
+ {
74
+ id: skill.id,
75
+ name: skill.name,
76
+ description: skill.description,
77
+ source: :template
78
+ }
79
+ end
80
+ end
81
+
82
+ # Get project skill names for display
83
+ #
84
+ # @return [Array<Hash>] Array of {id, name, description} hashes
85
+ def project_skill_list
86
+ project_skills.map do |skill|
87
+ {
88
+ id: skill.id,
89
+ name: skill.name,
90
+ description: skill.description,
91
+ source: :project
92
+ }
93
+ end
94
+ end
95
+
96
+ # Get combined list for selection
97
+ #
98
+ # @return [Array<Hash>] Array of {id, name, description, source} hashes
99
+ def skill_list
100
+ template_list + project_skill_list
101
+ end
102
+
103
+ private
104
+
105
+ # Load templates from gem directory
106
+ def load_templates
107
+ @templates = Loader.load_from_directory(templates_path)
108
+ Aidp.log_debug(
109
+ "wizard",
110
+ "Loaded templates",
111
+ count: @templates.size,
112
+ path: templates_path
113
+ )
114
+ end
115
+
116
+ # Load project skills
117
+ def load_project_skills
118
+ @project_skills = if Dir.exist?(project_skills_path)
119
+ Loader.load_from_directory(project_skills_path)
120
+ else
121
+ []
122
+ end
123
+
124
+ Aidp.log_debug(
125
+ "wizard",
126
+ "Loaded project skills",
127
+ count: @project_skills.size,
128
+ path: project_skills_path
129
+ )
130
+ end
131
+
132
+ # Check if templates are loaded
133
+ #
134
+ # @return [Boolean] True if loaded
135
+ def loaded?
136
+ !@templates.nil?
137
+ end
138
+
139
+ # Check if project skills are loaded
140
+ #
141
+ # @return [Boolean] True if loaded
142
+ def project_skills_loaded?
143
+ !@project_skills.nil?
144
+ end
145
+
146
+ # Get path to templates directory
147
+ #
148
+ # @return [String] Path to templates/skills directory
149
+ def templates_path
150
+ # Get the gem root directory (go up 4 levels from lib/aidp/skills/wizard/template_library.rb)
151
+ gem_root = File.expand_path("../../../..", __dir__)
152
+ File.join(gem_root, "templates", "skills")
153
+ end
154
+
155
+ # Get path to project skills directory
156
+ #
157
+ # @return [String] Path to .aidp/skills directory
158
+ def project_skills_path
159
+ File.join(project_dir, ".aidp", "skills")
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end