asciidoc-pubkit 1.1.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +33 -5
- data/data/review-rules.ja.yml +20 -3
- data/data/writing/ja/criteria.md +101 -1
- data/docs/agent-review-tutorial.md +76 -22
- data/docs/installation.md +19 -2
- data/docs/prose-evaluation.md +34 -3
- data/docs/review-workflow.md +27 -0
- data/docs/rules-and-coverage.md +13 -2
- data/docs/skill-commands.md +136 -0
- data/docs/skill-integration.md +95 -36
- data/docs/writing.md +17 -1
- data/examples/skills/review-asciidoc-manuscript/SKILL.md +20 -6
- data/lib/asciidoc_pubkit/cli.rb +7 -1
- data/lib/asciidoc_pubkit/session.rb +3 -0
- data/lib/asciidoc_pubkit/skill_commands.rb +246 -0
- data/lib/asciidoc_pubkit.rb +2 -1
- metadata +3 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'rubygems/version'
|
|
6
|
+
|
|
7
|
+
module AsciidocPubkit
|
|
8
|
+
# Manages bundled skills at an explicit path or agent-specific project path.
|
|
9
|
+
class SkillCommands
|
|
10
|
+
MANIFEST = '.asciidoc-pubkit-skill.json'
|
|
11
|
+
LOCK = '.asciidoc-pubkit-skills.lock'
|
|
12
|
+
AGENT_DIRECTORIES = { 'codex' => '.agents/skills', 'claude' => '.claude/skills' }.freeze
|
|
13
|
+
|
|
14
|
+
def self.bundled_root
|
|
15
|
+
File.expand_path('../../examples/skills', __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.run(command, args, out:, err:)
|
|
19
|
+
raise Error, 'Unknown skill command. Use skill --help.' unless %w[list install status upgrade].include?(command)
|
|
20
|
+
options = {}
|
|
21
|
+
parser = OptionParser.new do |opts|
|
|
22
|
+
opts.banner = "Usage: asciidoc-pubkit skill #{command}#{command == 'list' ? '' : ' NAME (--agent AGENT | --dir PATH)'} [options]"
|
|
23
|
+
unless command == 'list'
|
|
24
|
+
opts.on('--dir PATH', 'Explicit parent directory for installed skills') { |v| options[:dir] = v }
|
|
25
|
+
opts.on('--agent AGENT', AGENT_DIRECTORIES.keys, 'Project destination: codex or claude (does not invoke an agent)') { |v| options[:agent] = v }
|
|
26
|
+
end
|
|
27
|
+
opts.on('--json', 'Output structured skill information') { options[:json] = true } if %w[list status].include?(command)
|
|
28
|
+
opts.on('-h', '--help', 'Show command help') { options[:help] = true }
|
|
29
|
+
end
|
|
30
|
+
parser.parse!(args)
|
|
31
|
+
if options[:help]
|
|
32
|
+
out.puts parser
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
if command == 'list'
|
|
36
|
+
raise Error, 'skill list does not accept a name or destination.' unless args.empty?
|
|
37
|
+
skills = Dir.children(bundled_root).sort.select { |name| File.directory?(File.join(bundled_root, name)) }.map { |name| new(name).info }
|
|
38
|
+
out.puts(options[:json] ? JSON.pretty_generate({ 'tool_version' => VERSION, 'skills' => skills }) : skills.map { |s| "#{s['name']}: #{s['description']}" }.join("\n"))
|
|
39
|
+
return 0
|
|
40
|
+
end
|
|
41
|
+
if options[:dir] && options[:agent]
|
|
42
|
+
raise Error, 'Choose either --agent AGENT or --dir PATH, not both.'
|
|
43
|
+
end
|
|
44
|
+
directory = options[:dir] || AGENT_DIRECTORIES[options[:agent]]
|
|
45
|
+
unless args.length == 1 && directory && !directory.empty?
|
|
46
|
+
raise Error, 'Provide one bundled skill name and either --agent AGENT or --dir PATH.'
|
|
47
|
+
end
|
|
48
|
+
skill = new(args.first)
|
|
49
|
+
root = File.expand_path(directory)
|
|
50
|
+
check_path(root)
|
|
51
|
+
destination = File.join(root, skill.name)
|
|
52
|
+
if command == 'status'
|
|
53
|
+
report = skill.status(destination)
|
|
54
|
+
out.puts(options[:json] ? JSON.pretty_generate(report) : format_status(report))
|
|
55
|
+
return report['state'] == 'current' ? 0 : 1
|
|
56
|
+
end
|
|
57
|
+
if command == 'upgrade' && !%w[current update-available].include?(skill.status(destination)['state'])
|
|
58
|
+
raise Error, "Cannot upgrade this skill. Use skill status to inspect it; existing content was kept."
|
|
59
|
+
end
|
|
60
|
+
FileUtils.mkdir_p(root)
|
|
61
|
+
check_path(root)
|
|
62
|
+
lock = File.join(root, LOCK)
|
|
63
|
+
begin
|
|
64
|
+
Dir.mkdir(lock, 0o700)
|
|
65
|
+
rescue Errno::EEXIST
|
|
66
|
+
raise Error, "Skill destination is locked: #{lock}. Another install or upgrade may be running."
|
|
67
|
+
end
|
|
68
|
+
begin
|
|
69
|
+
result = skill.publish(destination, command)
|
|
70
|
+
ensure
|
|
71
|
+
Dir.rmdir(lock)
|
|
72
|
+
end
|
|
73
|
+
if result == :unchanged
|
|
74
|
+
out.puts "Already current: #{destination}"
|
|
75
|
+
return 0
|
|
76
|
+
end
|
|
77
|
+
out.puts "#{command == 'install' ? 'Installed' : 'Upgraded'} #{skill.name} from asciidoc-pubkit #{VERSION}: #{destination}"
|
|
78
|
+
0
|
|
79
|
+
rescue Error, OptionParser::ParseError, SystemCallError, JSON::ParserError, Psych::Exception, ArgumentError => e
|
|
80
|
+
err.puts "Error: #{e.message}"
|
|
81
|
+
2
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.check_path(path)
|
|
85
|
+
Pathname.new(path).ascend do |part|
|
|
86
|
+
raise Error, "Symlink paths are not supported: #{part}" if File.symlink?(part)
|
|
87
|
+
raise Error, "Expected a directory: #{part}" if File.exist?(part) && !File.directory?(part)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.format_status(report)
|
|
92
|
+
lines = ["Skill: #{report['name']}", "Destination: #{report['destination']}", "State: #{report['state']}",
|
|
93
|
+
"Installed pubkit: #{report['installed_version'] || 'unknown'}", "Available pubkit: #{VERSION}"]
|
|
94
|
+
%w[modified missing extra updated].each do |kind|
|
|
95
|
+
report.fetch(kind, []).each { |file| lines << "#{kind.capitalize}: #{file}" }
|
|
96
|
+
end
|
|
97
|
+
lines.join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
attr_reader :name
|
|
101
|
+
|
|
102
|
+
def initialize(name)
|
|
103
|
+
raise Error, 'Invalid skill name.' unless name.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) && name.length <= 64
|
|
104
|
+
@name = name
|
|
105
|
+
@source = File.join(self.class.bundled_root, name)
|
|
106
|
+
raise Error, "Unknown bundled skill: #{name}. Use skill list." unless File.directory?(@source)
|
|
107
|
+
self.class.check_path(@source)
|
|
108
|
+
@files = snapshot(@source)
|
|
109
|
+
raise Error, 'Bundled skill contains reserved registration metadata.' if @files.key?(MANIFEST)
|
|
110
|
+
text = AsciidocPubkit.read_text(File.join(@source, 'SKILL.md'))
|
|
111
|
+
frontmatter = text.match(/\A---\n(.*?)\n---(?:\n|\z)/m)
|
|
112
|
+
metadata = frontmatter && YAML.safe_load(frontmatter[1], permitted_classes: [], aliases: false)
|
|
113
|
+
unless metadata.is_a?(Hash) && metadata['name'] == name && metadata['description'].is_a?(String) && !metadata['description'].strip.empty?
|
|
114
|
+
raise Error, "Invalid bundled SKILL.md: #{name}"
|
|
115
|
+
end
|
|
116
|
+
@description = metadata['description']
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def info
|
|
120
|
+
{ 'name' => name, 'description' => @description, 'tool_version' => VERSION, 'files' => @files.keys.sort }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def snapshot(root)
|
|
124
|
+
files = {}
|
|
125
|
+
Find.find(root) do |path|
|
|
126
|
+
raise Error, "Symlink skill content is not supported: #{path}" if File.symlink?(path)
|
|
127
|
+
next if File.directory?(path)
|
|
128
|
+
raise Error, "Unsupported skill file: #{path}" unless File.file?(path)
|
|
129
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
130
|
+
files[relative] = Digest::SHA256.file(path).hexdigest
|
|
131
|
+
end
|
|
132
|
+
files
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def registration(destination)
|
|
136
|
+
path = File.join(destination, MANIFEST)
|
|
137
|
+
raise Error, "Symlink registration is not supported: #{path}" if File.symlink?(path)
|
|
138
|
+
return nil unless File.exist?(path)
|
|
139
|
+
raise Error, "Expected a registration file: #{path}" unless File.file?(path)
|
|
140
|
+
data = JSON.parse(AsciidocPubkit.read_text(path))
|
|
141
|
+
valid = data.is_a?(Hash) && data.keys.sort == %w[files name schema_version tool tool_version] &&
|
|
142
|
+
data['schema_version'] == 1 && data['tool'] == 'asciidoc-pubkit' && data['name'] == name &&
|
|
143
|
+
data['tool_version'].is_a?(String) && !data['tool_version'].empty? && Gem::Version.correct?(data['tool_version']) &&
|
|
144
|
+
data['files'].is_a?(Hash) && data['files'].key?('SKILL.md') && !data['files'].key?(MANIFEST)
|
|
145
|
+
valid &&= data['files'].all? do |file, sha|
|
|
146
|
+
file.is_a?(String) && !file.empty? && !file.start_with?('/') && !file.include?('\\') &&
|
|
147
|
+
file.split('/', -1).none? { |part| ['', '.', '..'].include?(part) || part.include?("\0") } &&
|
|
148
|
+
sha.is_a?(String) && sha.match?(/\A[0-9a-f]{64}\z/)
|
|
149
|
+
end
|
|
150
|
+
raise Error, "Invalid skill registration: #{path}" unless valid
|
|
151
|
+
data
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def status(destination)
|
|
155
|
+
self.class.check_path(destination)
|
|
156
|
+
report = { 'name' => name, 'destination' => destination, 'available_version' => VERSION,
|
|
157
|
+
'installed_version' => nil, 'state' => 'not-installed', 'modified' => [], 'missing' => [], 'extra' => [], 'updated' => [] }
|
|
158
|
+
return report unless File.directory?(destination)
|
|
159
|
+
data = registration(destination)
|
|
160
|
+
return report.merge('state' => 'unmanaged') unless data
|
|
161
|
+
actual = snapshot(destination).reject { |file, _| file == MANIFEST }
|
|
162
|
+
expected = data['files']
|
|
163
|
+
report['installed_version'] = data['tool_version']
|
|
164
|
+
report['modified'] = (expected.keys & actual.keys).select { |file| expected[file] != actual[file] }.sort
|
|
165
|
+
report['missing'] = (expected.keys - actual.keys).sort
|
|
166
|
+
report['extra'] = (actual.keys - expected.keys).sort
|
|
167
|
+
# Even empty additional directories are user-owned content.
|
|
168
|
+
permitted_dirs = expected.keys.flat_map do |file|
|
|
169
|
+
parts = file.split('/')[0...-1]
|
|
170
|
+
(1..parts.length).map { |length| parts.first(length).join('/') }
|
|
171
|
+
end
|
|
172
|
+
Find.find(destination) do |path|
|
|
173
|
+
next unless File.directory?(path) && path != destination
|
|
174
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(destination)).to_s
|
|
175
|
+
report['extra'] << relative + '/' unless permitted_dirs.include?(relative)
|
|
176
|
+
end
|
|
177
|
+
report['extra'].sort!
|
|
178
|
+
report['updated'] = (expected.keys | @files.keys).select { |file| expected[file] != @files[file] }.sort
|
|
179
|
+
report['state'] = if %w[modified missing extra].any? { |kind| !report[kind].empty? }
|
|
180
|
+
'modified'
|
|
181
|
+
elsif Gem::Version.new(data['tool_version']) > Gem::Version.new(VERSION)
|
|
182
|
+
'newer-installed'
|
|
183
|
+
elsif data['tool_version'] != VERSION || !report['updated'].empty?
|
|
184
|
+
'update-available'
|
|
185
|
+
else
|
|
186
|
+
'current'
|
|
187
|
+
end
|
|
188
|
+
report
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def publish(destination, command)
|
|
192
|
+
self.class.check_path(destination)
|
|
193
|
+
if command == 'install'
|
|
194
|
+
raise Error, "Skill destination already exists: #{destination}. Use skill status or choose a different directory." if File.exist?(destination)
|
|
195
|
+
else
|
|
196
|
+
report = status(destination)
|
|
197
|
+
unless %w[current update-available].include?(report['state'])
|
|
198
|
+
raise Error, "Cannot upgrade #{report['state']} skill: #{destination}. Use skill status to inspect it; existing content was kept."
|
|
199
|
+
end
|
|
200
|
+
return :unchanged if report['state'] == 'current'
|
|
201
|
+
end
|
|
202
|
+
workspace = Dir.mktmpdir('.asciidoc-pubkit-skill-', File.dirname(destination))
|
|
203
|
+
staged = File.join(workspace, 'new')
|
|
204
|
+
backup = File.join(workspace, 'old')
|
|
205
|
+
preserve_backup = false
|
|
206
|
+
begin
|
|
207
|
+
Dir.mkdir(staged)
|
|
208
|
+
@files.each do |file, sha|
|
|
209
|
+
source = File.join(@source, file)
|
|
210
|
+
raise Error, 'Bundled skill changed while staging.' unless Digest::SHA256.file(source).hexdigest == sha
|
|
211
|
+
target = File.join(staged, file)
|
|
212
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
213
|
+
FileUtils.copy_file(source, target)
|
|
214
|
+
File.chmod(File.stat(source).mode & 0o777, target)
|
|
215
|
+
raise Error, 'Bundled skill changed while copying.' unless Digest::SHA256.file(target).hexdigest == sha
|
|
216
|
+
end
|
|
217
|
+
File.write(File.join(staged, MANIFEST), JSON.pretty_generate({ 'schema_version' => 1, 'tool' => 'asciidoc-pubkit',
|
|
218
|
+
'name' => name, 'tool_version' => VERSION, 'files' => @files }) + "\n")
|
|
219
|
+
self.class.check_path(destination)
|
|
220
|
+
if command == 'upgrade'
|
|
221
|
+
raise Error, 'Installed skill changed while staging.' unless status(destination) == report
|
|
222
|
+
File.rename(destination, backup)
|
|
223
|
+
elsif File.exist?(destination)
|
|
224
|
+
raise Error, "Skill destination already exists: #{destination}"
|
|
225
|
+
end
|
|
226
|
+
begin
|
|
227
|
+
File.rename(staged, destination)
|
|
228
|
+
rescue SystemCallError
|
|
229
|
+
if File.directory?(backup)
|
|
230
|
+
preserve_backup = true
|
|
231
|
+
begin
|
|
232
|
+
File.rename(backup, destination)
|
|
233
|
+
preserve_backup = false
|
|
234
|
+
rescue SystemCallError => restore_error
|
|
235
|
+
raise Error, "Could not restore the skill. Original content is preserved at #{backup}: #{restore_error.message}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
raise
|
|
239
|
+
end
|
|
240
|
+
ensure
|
|
241
|
+
FileUtils.remove_entry_secure(workspace) unless preserve_backup
|
|
242
|
+
end
|
|
243
|
+
:updated
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/asciidoc_pubkit.rb
CHANGED
|
@@ -9,7 +9,7 @@ require 'pathname'
|
|
|
9
9
|
require 'optparse'
|
|
10
10
|
|
|
11
11
|
module AsciidocPubkit
|
|
12
|
-
VERSION = '1.1
|
|
12
|
+
VERSION = '1.2.1'
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
15
|
def self.hash_text(text)
|
|
@@ -36,4 +36,5 @@ require_relative 'asciidoc_pubkit/local_evaluator'
|
|
|
36
36
|
require_relative 'asciidoc_pubkit/score'
|
|
37
37
|
require_relative 'asciidoc_pubkit/replacement'
|
|
38
38
|
require_relative 'asciidoc_pubkit/document_commands'
|
|
39
|
+
require_relative 'asciidoc_pubkit/skill_commands'
|
|
39
40
|
require_relative 'asciidoc_pubkit/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciidoc-pubkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1
|
|
4
|
+
version: 1.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CyberGarage
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- docs/review-workflow.md
|
|
71
71
|
- docs/rules-and-coverage.md
|
|
72
72
|
- docs/scoring.md
|
|
73
|
+
- docs/skill-commands.md
|
|
73
74
|
- docs/skill-integration.md
|
|
74
75
|
- docs/writing.md
|
|
75
76
|
- examples/book.adoc
|
|
@@ -90,6 +91,7 @@ files:
|
|
|
90
91
|
- lib/asciidoc_pubkit/score.rb
|
|
91
92
|
- lib/asciidoc_pubkit/session.rb
|
|
92
93
|
- lib/asciidoc_pubkit/settings.rb
|
|
94
|
+
- lib/asciidoc_pubkit/skill_commands.rb
|
|
93
95
|
- lib/asciidoc_pubkit/writing.rb
|
|
94
96
|
homepage: https://github.com/cybergarage/asciidoc-pubkit
|
|
95
97
|
licenses:
|