ask-skills 0.3.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 944cddd3dbc0de89913d1deb45668542f189e154bf3d40cc00468184689aeb38
4
- data.tar.gz: a200f4181fa77aabc25dc6e1d0e2454d450159e5c64fa0cf7acf698910b8269b
3
+ metadata.gz: c3467a3de9f58a23327ca0624d3440f6eed9983bb4d8f834db30725d16eea52a
4
+ data.tar.gz: 4cd313ba7ef8419fe9683ea7f7d4d6ce88a0270082e1b7f7d2d74092b320bb86
5
5
  SHA512:
6
- metadata.gz: bf049da94d308df2c5bb89164adc488abcc625ef65db0e2e14d73b981c03c9a0fe5d8dcf53b7f4037c437c1d3464a6515edf170ad439e28bf2f2afcf0fc7abb7
7
- data.tar.gz: '03947665d57b76371ce167641b2b98a7d6e8f0587cc2af11c134a282906322c1f72cc4b7d45972c19738189d50eb45fc23cccffbbf1341a6c14dd663f26e0eff'
6
+ metadata.gz: 101a0a858c1ee4e0f414dfb2964984096094c397e8e14014181875c95dea3b67ed7c3e3ddec201da831e5f0aaa973176e69be71ac6b563278eecad8b5761d88e
7
+ data.tar.gz: ab7c5ec0eaa6c1887076a3b5314c5e83f5ef6eee1a411bc1de7371ef1c3b2b01d0636f483fa6a850b09ebc7c0d5e64099458747b0891a6d9b9dc3258960968a6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ ## [0.4.0] - 2026-07-21
2
+
3
+ ### Added
4
+
5
+ - **Enhanced frontmatter** — skill metadata now supports `tags`, `version`, `author`, and any custom fields alongside `name` and `description`. Frontmatter parser handles multi-line values and preserves all metadata in `skill.metadata`.
6
+
7
+ ```markdown
8
+ ---
9
+ name: rails_debug
10
+ description: Debug Rails database issues
11
+ tags: rails, database, debugging
12
+ version: 2
13
+ ---
14
+ ```
15
+
16
+ - **Sibling files in skills** — skills now discover sibling files and directories in the skill directory alongside `SKILL.md`. Recognized categories are discovered automatically: `references/`, `scripts/`, `assets/`. Unrecognized directories and flat files are also collected.
17
+
18
+ ```
19
+ rails_debug/
20
+ ├── SKILL.md → instructions
21
+ ├── references/
22
+ │ ├── migration_guide.md
23
+ │ └── apis.md
24
+ └── scripts/
25
+ └── db_check.sh
26
+ ```
27
+
28
+ Accessible at runtime via `skill.references`, `skill.scripts`, `skill.assets`, and `skill.siblings`.
29
+
30
+ - **XML formatter** now includes `<tags>` and `<siblings>` sections when present. Markdown prompt entries show tags inline (`[rails, database]`).
31
+
32
+ - **`askr skills` CLI commands** — new `askr` subcommands for managing skills:
33
+
34
+ ```bash
35
+ askr skills list # All skills with descriptions and tags
36
+ askr skills show rails_debug # Full details + instructions + siblings
37
+ askr skills search deploy # Filter by name, description, or tags
38
+ ```
39
+
40
+ ### Changed
41
+
42
+ - `Skill` data object now includes `metadata` (Hash) and `siblings` (Hash) fields with default empty values. Backward compatible — existing `Skill.new(...)` calls without these fields get empty defaults.
43
+ - `Source::Filesystem#parse_frontmatter` rewritten to support multi-line values and arbitrary metadata keys (not just `name`/`description`).
44
+ - `Ask::Skills.parse_frontmatter` updated to match, with new `process_metadata_value` helper.
45
+
46
+ ### Tested
47
+
48
+ - 18 new tests for: tags parsing, metadata preservation, sibling discovery (references, scripts, assets, flat files), hidden file filtering, empty sibling handling, formatter output with tags/siblings, and CLI commands.
49
+ - Full suite: 97 tests, 265 assertions — 0 failures.
50
+
1
51
  ## [0.3.0] - 2026-07-21
2
52
 
3
53
  ### Added
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ask
2
4
  module Skills
3
5
  class Formatter
@@ -10,7 +12,11 @@ module Ask
10
12
 
11
13
  lines = ["", "## Available Skills", ""]
12
14
  @skills.each_value do |skill|
13
- lines << skill.to_prompt_entry
15
+ line = skill.to_prompt_entry
16
+ if skill.tags.any?
17
+ line += " [#{skill.tags.join(", ")}]"
18
+ end
19
+ lines << line
14
20
  end
15
21
  lines << ""
16
22
  lines << "When a task matches a skill's description, load it for step-by-step methodology."
@@ -26,6 +32,18 @@ module Ask
26
32
  lines << " <skill>"
27
33
  lines << " <name>#{escape_xml(skill.name)}</name>"
28
34
  lines << " <description>#{escape_xml(skill.description)}</description>"
35
+ if skill.tags.any?
36
+ lines << " <tags>#{escape_xml(skill.tags.join(", "))}</tags>"
37
+ end
38
+ if skill.siblings.any?
39
+ lines << " <siblings>"
40
+ skill.siblings.each do |category, files|
41
+ lines << " <#{category}>"
42
+ files.each { |f| lines << " <file>#{escape_xml(f)}</file>" }
43
+ lines << " </#{category}>"
44
+ end
45
+ lines << " </siblings>"
46
+ end
29
47
  lines << " </skill>"
30
48
  end
31
49
  lines << "</available_skills>"
@@ -19,12 +19,14 @@ module Ask
19
19
 
20
20
  def format_for_prompt
21
21
  return "" if @skills.empty?
22
- lines = ["", "## Available Skills", ""]
23
- @skills.each_value do |skill|
24
- lines << skill.to_prompt_entry
25
- end
26
- lines << ""
27
- lines.join("\n")
22
+ Formatter.new(@skills).to_prompt_section
23
+ end
24
+
25
+ # Full instructions for skills with +always: true+ in their frontmatter.
26
+ # These skills are auto-injected into the system prompt rather than
27
+ # being listed for the LLM to load on demand.
28
+ def always_active_skills
29
+ @skills.values.select { |s| s.metadata["always"] == "true" || s.metadata["always"] == true }
28
30
  end
29
31
 
30
32
  private
@@ -33,8 +35,13 @@ module Ask
33
35
  @sources.each do |source|
34
36
  source.load.each do |skill|
35
37
  next unless skill
36
- # First source wins (project overrides gems, user overrides project)
37
- @skills[skill.name] ||= skill
38
+ if @skills.key?(skill.name)
39
+ # First-wins collision: log diagnostic but keep first
40
+ warn "[ask-skills] Collision: skill '#{skill.name}' already loaded from " \
41
+ "#{@skills[skill.name].source}, skipping #{skill.source}"
42
+ else
43
+ @skills[skill.name] = skill
44
+ end
38
45
  end
39
46
  end
40
47
  end
@@ -1,12 +1,58 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ask
2
4
  module Skills
3
- Skill = Data.define(:name, :description, :instructions, :source) do
5
+ # A discovered skill with its metadata, instructions, and sibling files.
6
+ #
7
+ # Skills live in directories following the SKILL.md convention:
8
+ #
9
+ # rails_debug/
10
+ # ├── SKILL.md → name, description, tags, instructions
11
+ # ├── references/ → reference documents (loaded on demand)
12
+ # ├── scripts/ → executable helpers
13
+ # └── assets/ → images, templates, other resources
14
+ #
15
+ # The +siblings+ hash maps category names to arrays of file paths,
16
+ # relative to the skill's directory. Categories are inferred from
17
+ # sibling directory names (references, scripts, assets) or files.
18
+ Skill = Data.define(:name, :description, :instructions, :source, :metadata, :siblings) do
19
+ def initialize(name:, description:, instructions:, source:, metadata: {}, siblings: {})
20
+ super(name: name, description: description, instructions: instructions,
21
+ source: source, metadata: metadata, siblings: siblings)
22
+ end
23
+
4
24
  def to_s
5
25
  "#{name}: #{description}"
6
26
  end
7
27
 
8
28
  def to_prompt_entry
9
- "- **#{name}**: #{description}"
29
+ line = "- **#{name}**: #{description}"
30
+ line
31
+ end
32
+
33
+ # Reference documents bundled with this skill.
34
+ # @return [Array<String>] file paths relative to skill directory
35
+ def references
36
+ siblings["references"] || []
37
+ end
38
+
39
+ # Executable scripts bundled with this skill.
40
+ # @return [Array<String>] file paths relative to skill directory
41
+ def scripts
42
+ siblings["scripts"] || []
43
+ end
44
+
45
+ # Asset files bundled with this skill.
46
+ # @return [Array<String>] file paths relative to skill directory
47
+ def assets
48
+ siblings["assets"] || []
49
+ end
50
+
51
+ # Comma-separated tags from frontmatter.
52
+ # @return [Array<String>]
53
+ def tags
54
+ raw = metadata["tags"] || metadata[:tags] || ""
55
+ raw.split(",").map(&:strip).reject(&:empty?)
10
56
  end
11
57
  end
12
58
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ask
2
4
  module Skills
3
5
  module Source
@@ -35,25 +37,90 @@ module Ask
35
37
 
36
38
  return nil if description.empty?
37
39
 
38
- Skill.new(name: name, description: description, instructions: body, source: path)
40
+ # Separate known metadata from reserved fields
41
+ reserved = %w[name description]
42
+ metadata = frontmatter.reject { |k, _| reserved.include?(k) }
43
+
44
+ # Discover sibling files
45
+ skill_dir = File.dirname(path)
46
+ siblings = discover_siblings(skill_dir)
47
+
48
+ Skill.new(
49
+ name: name,
50
+ description: description,
51
+ instructions: body,
52
+ source: path,
53
+ metadata: metadata,
54
+ siblings: siblings
55
+ )
56
+ end
57
+
58
+ # Scan the skill directory for sibling files and categorize them.
59
+ def discover_siblings(skill_dir)
60
+ siblings = {}
61
+ return siblings unless File.directory?(skill_dir)
62
+
63
+ Dir.entries(skill_dir).each do |entry|
64
+ next if entry.start_with?(".")
65
+ next if entry == "SKILL.md"
66
+ entry_path = File.join(skill_dir, entry)
67
+
68
+ if File.directory?(entry_path)
69
+ # Categorized sibling directories
70
+ category = entry
71
+ files = Dir.children(entry_path)
72
+ .reject { |f| f.start_with?(".") }
73
+ .map { |f| File.join(entry, f) }
74
+ siblings[category] = files if files.any?
75
+ else
76
+ # Individual files go into a general "files" category
77
+ siblings["files"] ||= []
78
+ siblings["files"] << entry
79
+ end
80
+ end
81
+
82
+ siblings
39
83
  end
40
84
 
41
85
  def parse_frontmatter(content)
42
86
  return {} unless content.start_with?("---\n")
43
87
  end_idx = content.index("\n---\n", 4)
44
88
  return {} unless end_idx
89
+
45
90
  yaml_str = content[4...end_idx]
46
91
  yaml = {}
92
+ current_key = nil
93
+ current_value = nil
94
+
47
95
  yaml_str.split("\n").each do |line|
48
- if (m = line.match(/\A(\w+):\s*(.+)\z/))
49
- value = m[2].strip
50
- value = value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
51
- yaml[m[1]] = value
96
+ if (m = line.match(/\A(\w[\w_]*):\s*(.*)\z/))
97
+ # Store previous key if any
98
+ if current_key
99
+ yaml[current_key] = process_value(current_value.strip)
100
+ end
101
+ current_key = m[1]
102
+ current_value = m[2].strip
103
+ elsif current_key && line.match?(/\A\s+/)
104
+ # Continuation of multi-line value (e.g. tags on next line)
105
+ current_value << " #{line.strip}"
52
106
  end
53
107
  end
108
+
109
+ # Store last key
110
+ if current_key
111
+ yaml[current_key] = process_value(current_value.strip)
112
+ end
113
+
54
114
  yaml
55
115
  end
56
116
 
117
+ def process_value(value)
118
+ return value unless value
119
+ # Remove surrounding quotes
120
+ value = value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
121
+ value
122
+ end
123
+
57
124
  def extract_body(content)
58
125
  return content unless content.start_with?("---\n")
59
126
  end_idx = content.index("\n---\n", 4)
@@ -1,5 +1,5 @@
1
1
  module Ask
2
2
  module Skills
3
- VERSION = "0.3.0"
3
+ VERSION = "0.4.1"
4
4
  end
5
5
  end
data/lib/ask/skills.rb CHANGED
@@ -61,18 +61,36 @@ module Ask
61
61
  return {} unless content.start_with?("---\n")
62
62
  end_idx = content.index("\n---\n", 4)
63
63
  return {} unless end_idx
64
+
64
65
  yaml_str = content[4...end_idx]
65
66
  yaml = {}
67
+ current_key = nil
68
+ current_value = nil
69
+
66
70
  yaml_str.split("\n").each do |line|
67
- if (m = line.match(/\A(\w+):\s*(.+)\z/))
68
- value = m[2].strip
69
- value = value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
70
- yaml[m[1]] = value
71
+ if (m = line.match(/\A(\w[\w_]*):\s*(.*)\z/))
72
+ if current_key
73
+ yaml[current_key] = process_metadata_value(current_value.strip)
74
+ end
75
+ current_key = m[1]
76
+ current_value = m[2].strip
77
+ elsif current_key && line.match?(/\A\s+/)
78
+ current_value << " #{line.strip}"
71
79
  end
72
80
  end
81
+
82
+ if current_key
83
+ yaml[current_key] = process_metadata_value(current_value.strip)
84
+ end
85
+
73
86
  yaml
74
87
  end
75
88
 
89
+ def process_metadata_value(value)
90
+ return value unless value
91
+ value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
92
+ end
93
+
76
94
  # Extract the markdown body from a file with frontmatter.
77
95
  def extract_body(content)
78
96
  return content unless content.start_with?("---\n")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-skills
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto