asciidoc-pubkit 1.1.0 → 1.2.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,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
@@ -9,7 +9,7 @@ require 'pathname'
9
9
  require 'optparse'
10
10
 
11
11
  module AsciidocPubkit
12
- VERSION = '1.1.0'
12
+ VERSION = '1.2.0'
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.0
4
+ version: 1.2.0
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: