ask-skills 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 944cddd3dbc0de89913d1deb45668542f189e154bf3d40cc00468184689aeb38
4
- data.tar.gz: a200f4181fa77aabc25dc6e1d0e2454d450159e5c64fa0cf7acf698910b8269b
3
+ metadata.gz: 768239027d0fbaa9696f54f17dab21af118661b7d36c89969e2e8408ff75d8c0
4
+ data.tar.gz: 02ac858fc1be5fa4d9ffd93909cf828d8dafb6b9d134a4aac41ba8e3dfe46f38
5
5
  SHA512:
6
- metadata.gz: bf049da94d308df2c5bb89164adc488abcc625ef65db0e2e14d73b981c03c9a0fe5d8dcf53b7f4037c437c1d3464a6515edf170ad439e28bf2f2afcf0fc7abb7
7
- data.tar.gz: '03947665d57b76371ce167641b2b98a7d6e8f0587cc2af11c134a282906322c1f72cc4b7d45972c19738189d50eb45fc23cccffbbf1341a6c14dd663f26e0eff'
6
+ metadata.gz: a242038997b6697ce44fcea6372bcc7ce797dc1ce510e2a5d36216256efdaa34f41d095c2909851f0db8dc2fc12137e934d826fa0cc3eb2bbc8ec1f854161045
7
+ data.tar.gz: 478b061e75189527782cff6c567c37b2f629b5594e6839427536a87260fcc89cc383adc4e6db172338d33a8289d5a09b851788532aab3fd07fd8b531611786c4
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>"
@@ -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,7 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ask
2
4
  module Skills
3
5
  module Source
4
6
  class Filesystem < Base
7
+ SIBLING_CATEGORIES = %w[references scripts assets].freeze
8
+
5
9
  def initialize(dir: nil, project_dir: nil, user_dir: nil)
6
10
  @path = dir || project_dir || (user_dir ? File.expand_path(user_dir) : nil)
7
11
  end
@@ -35,25 +39,90 @@ module Ask
35
39
 
36
40
  return nil if description.empty?
37
41
 
38
- Skill.new(name: name, description: description, instructions: body, source: path)
42
+ # Separate known metadata from reserved fields
43
+ reserved = %w[name description]
44
+ metadata = frontmatter.reject { |k, _| reserved.include?(k) }
45
+
46
+ # Discover sibling files
47
+ skill_dir = File.dirname(path)
48
+ siblings = discover_siblings(skill_dir)
49
+
50
+ Skill.new(
51
+ name: name,
52
+ description: description,
53
+ instructions: body,
54
+ source: path,
55
+ metadata: metadata,
56
+ siblings: siblings
57
+ )
58
+ end
59
+
60
+ # Scan the skill directory for sibling files and categorize them.
61
+ def discover_siblings(skill_dir)
62
+ siblings = {}
63
+ return siblings unless File.directory?(skill_dir)
64
+
65
+ Dir.entries(skill_dir).each do |entry|
66
+ next if entry.start_with?(".")
67
+ next if entry == "SKILL.md"
68
+ entry_path = File.join(skill_dir, entry)
69
+
70
+ if File.directory?(entry_path)
71
+ # Categorized sibling directories
72
+ category = entry
73
+ files = Dir.children(entry_path)
74
+ .reject { |f| f.start_with?(".") }
75
+ .map { |f| File.join(entry, f) }
76
+ siblings[category] = files if files.any?
77
+ else
78
+ # Individual files go into a general "files" category
79
+ siblings["files"] ||= []
80
+ siblings["files"] << entry
81
+ end
82
+ end
83
+
84
+ siblings
39
85
  end
40
86
 
41
87
  def parse_frontmatter(content)
42
88
  return {} unless content.start_with?("---\n")
43
89
  end_idx = content.index("\n---\n", 4)
44
90
  return {} unless end_idx
91
+
45
92
  yaml_str = content[4...end_idx]
46
93
  yaml = {}
94
+ current_key = nil
95
+ current_value = nil
96
+
47
97
  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
98
+ if (m = line.match(/\A(\w[\w_]*):\s*(.*)\z/))
99
+ # Store previous key if any
100
+ if current_key
101
+ yaml[current_key] = process_value(current_value.strip)
102
+ end
103
+ current_key = m[1]
104
+ current_value = m[2].strip
105
+ elsif current_key && line.match?(/\A\s+/)
106
+ # Continuation of multi-line value (e.g. tags on next line)
107
+ current_value << " #{line.strip}"
52
108
  end
53
109
  end
110
+
111
+ # Store last key
112
+ if current_key
113
+ yaml[current_key] = process_value(current_value.strip)
114
+ end
115
+
54
116
  yaml
55
117
  end
56
118
 
119
+ def process_value(value)
120
+ return value unless value
121
+ # Remove surrounding quotes
122
+ value = value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
123
+ value
124
+ end
125
+
57
126
  def extract_body(content)
58
127
  return content unless content.start_with?("---\n")
59
128
  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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto