ask-skills 0.2.2 → 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 +4 -4
- data/CHANGELOG.md +88 -0
- data/lib/ask/skills/formatter.rb +19 -1
- data/lib/ask/skills/skill.rb +48 -2
- data/lib/ask/skills/sources/filesystem.rb +74 -5
- data/lib/ask/skills/version.rb +1 -1
- data/lib/ask/skills.rb +67 -22
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 768239027d0fbaa9696f54f17dab21af118661b7d36c89969e2e8408ff75d8c0
|
|
4
|
+
data.tar.gz: 02ac858fc1be5fa4d9ffd93909cf828d8dafb6b9d134a4aac41ba8e3dfe46f38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a242038997b6697ce44fcea6372bcc7ce797dc1ce510e2a5d36216256efdaa34f41d095c2909851f0db8dc2fc12137e934d826fa0cc3eb2bbc8ec1f854161045
|
|
7
|
+
data.tar.gz: 478b061e75189527782cff6c567c37b2f629b5594e6839427536a87260fcc89cc383adc4e6db172338d33a8289d5a09b851788532aab3fd07fd8b531611786c4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
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
|
+
|
|
51
|
+
## [0.3.0] - 2026-07-21
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **New discovery paths** — skills are now discovered from `agents/shared/skills/` and `app/agents/shared/skills/` alongside the legacy `.agents/skills/`. These paths follow the existing `agents/` convention already established for agent definitions.
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
agents/
|
|
59
|
+
├── health_check/
|
|
60
|
+
├── daily_report/
|
|
61
|
+
└── shared/
|
|
62
|
+
├── tools/ ← shared tools
|
|
63
|
+
└── skills/ ← new: shared skills
|
|
64
|
+
└── rails_debug/SKILL.md
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **Per-agent skills** — `Ask::Skills.discover(agent_dir:)` discovers skills scoped to a specific agent directory. These have highest priority over shared, legacy, user, gem, and built-in skills.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
agents/health_check/
|
|
71
|
+
├── agent.rb
|
|
72
|
+
├── instructions.md
|
|
73
|
+
└── skills/ ← new: only available to health_check agent
|
|
74
|
+
└── nginx_debug/SKILL.md
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **ask-agent integration** — `Ask::Agent::Session` accepts `agent_dir:` parameter. When creating a session from an agent definition via `Ask::Agent.new("name")`, per-agent skills are auto-discovered and included. No configuration needed.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- `Ask::Skills.discover` now accepts `agent_dir:` keyword. When provided, the source list starts with the per-agent skills directory (highest priority). Shared project skills from `agents/shared/skills/` and `app/agents/shared/skills/` are included in the default source list.
|
|
82
|
+
- Backward compatible — legacy `.agents/skills/` continues to work.
|
|
83
|
+
|
|
84
|
+
### Tested
|
|
85
|
+
|
|
86
|
+
- 10 new integration tests: discovery from `agents/shared/skills/`, `app/agents/shared/skills/`, per-agent skills, priority ordering, backward compatibility with `.agents/skills/`, and source list verification.
|
|
87
|
+
- Full suite: 79 tests, 217 assertions — 0 failures.
|
|
88
|
+
|
|
1
89
|
## [0.2.2] - 2026-06-25
|
|
2
90
|
|
|
3
91
|
### Changed
|
data/lib/ask/skills/formatter.rb
CHANGED
|
@@ -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
|
-
|
|
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>"
|
data/lib/ask/skills/skill.rb
CHANGED
|
@@ -1,12 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Ask
|
|
2
4
|
module Skills
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)
|
data/lib/ask/skills/version.rb
CHANGED
data/lib/ask/skills.rb
CHANGED
|
@@ -16,33 +16,29 @@ module Ask
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
class << self
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# Discover skills from all configured sources.
|
|
20
|
+
#
|
|
21
|
+
# @param agent_dir [String, nil] optional agent directory to discover
|
|
22
|
+
# per-agent skills from (e.g. "agents/health_check")
|
|
23
|
+
# @param sources [Array<Source::Base>, nil] custom source list
|
|
24
|
+
# @return [Registry]
|
|
25
|
+
def discover(sources: nil, agent_dir: nil)
|
|
26
|
+
all_sources = sources || build_source_list(agent_dir: agent_dir)
|
|
27
|
+
Registry.new(all_sources)
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
# Default sources when no custom sources or agent directory given.
|
|
31
|
+
# Legacy `.agents/skills/` is kept for backward compatibility.
|
|
23
32
|
def default_sources
|
|
24
|
-
|
|
25
|
-
# Highest priority first — first source wins in Registry
|
|
26
|
-
Source::Filesystem.new(project_dir: ".agents/skills"),
|
|
27
|
-
Source::Filesystem.new(user_dir: "~/.config/ask/skills"),
|
|
28
|
-
Source::Gems.new,
|
|
29
|
-
Source::Filesystem.new(dir: builtin_skills_dir),
|
|
30
|
-
]
|
|
33
|
+
build_source_list
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
# Built-in skills that ship with the gem.
|
|
33
37
|
def builtin_skills_dir
|
|
34
|
-
# Skills live in lib/ask/skills/<skill_name>/SKILL.md
|
|
35
|
-
# __dir__ in this file (lib/ask/skills.rb) is lib/ask/
|
|
36
|
-
# The skill directories are in lib/ask/skills/
|
|
37
38
|
File.join(__dir__, "skills")
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Load a skill from an arbitrary markdown file path.
|
|
41
|
-
# Parses frontmatter if present, otherwise uses the filename as the skill name.
|
|
42
|
-
#
|
|
43
|
-
# @param path [String] absolute or relative path to a markdown (.md) file
|
|
44
|
-
# @return [Skill] a skill with the file's content as instructions
|
|
45
|
-
# @raise [Errno::ENOENT] if the file does not exist
|
|
46
42
|
def load_file(path)
|
|
47
43
|
path = File.expand_path(path)
|
|
48
44
|
content = File.read(path)
|
|
@@ -61,23 +57,40 @@ module Ask
|
|
|
61
57
|
end
|
|
62
58
|
|
|
63
59
|
# Simple frontmatter parsing for skill files.
|
|
64
|
-
# Returns a hash of key-value pairs from between --- markers.
|
|
65
60
|
def parse_frontmatter(content)
|
|
66
61
|
return {} unless content.start_with?("---\n")
|
|
67
62
|
end_idx = content.index("\n---\n", 4)
|
|
68
63
|
return {} unless end_idx
|
|
64
|
+
|
|
69
65
|
yaml_str = content[4...end_idx]
|
|
70
66
|
yaml = {}
|
|
67
|
+
current_key = nil
|
|
68
|
+
current_value = nil
|
|
69
|
+
|
|
71
70
|
yaml_str.split("\n").each do |line|
|
|
72
|
-
if (m = line.match(/\A(\w
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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}"
|
|
76
79
|
end
|
|
77
80
|
end
|
|
81
|
+
|
|
82
|
+
if current_key
|
|
83
|
+
yaml[current_key] = process_metadata_value(current_value.strip)
|
|
84
|
+
end
|
|
85
|
+
|
|
78
86
|
yaml
|
|
79
87
|
end
|
|
80
88
|
|
|
89
|
+
def process_metadata_value(value)
|
|
90
|
+
return value unless value
|
|
91
|
+
value.gsub(/\A"|"\z/, "").gsub(/\A'|'\z/, "")
|
|
92
|
+
end
|
|
93
|
+
|
|
81
94
|
# Extract the markdown body from a file with frontmatter.
|
|
82
95
|
def extract_body(content)
|
|
83
96
|
return content unless content.start_with?("---\n")
|
|
@@ -86,6 +99,38 @@ module Ask
|
|
|
86
99
|
body = content[(end_idx + 5)..] || ""
|
|
87
100
|
body.sub(/\A\n/, "").strip
|
|
88
101
|
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Build the prioritized source list.
|
|
106
|
+
# Order: per-agent → shared project → legacy → user → gems → built-in
|
|
107
|
+
def build_source_list(agent_dir: nil)
|
|
108
|
+
sources = []
|
|
109
|
+
|
|
110
|
+
# Per-agent skills (highest priority — agent-specific first)
|
|
111
|
+
if agent_dir
|
|
112
|
+
per_agent = File.join(agent_dir, "skills")
|
|
113
|
+
sources << Source::Filesystem.new(dir: per_agent)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Shared project skills
|
|
117
|
+
sources << Source::Filesystem.new(project_dir: "agents/shared/skills")
|
|
118
|
+
sources << Source::Filesystem.new(project_dir: "app/agents/shared/skills")
|
|
119
|
+
|
|
120
|
+
# Legacy project skills (backward compat)
|
|
121
|
+
sources << Source::Filesystem.new(project_dir: ".agents/skills")
|
|
122
|
+
|
|
123
|
+
# User-global skills
|
|
124
|
+
sources << Source::Filesystem.new(user_dir: "~/.config/ask/skills")
|
|
125
|
+
|
|
126
|
+
# Gems
|
|
127
|
+
sources << Source::Gems.new
|
|
128
|
+
|
|
129
|
+
# Built-in
|
|
130
|
+
sources << Source::Filesystem.new(dir: builtin_skills_dir)
|
|
131
|
+
|
|
132
|
+
sources
|
|
133
|
+
end
|
|
89
134
|
end
|
|
90
135
|
end
|
|
91
136
|
end
|