skill 0.1.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.
data/lib/skill/cli.rb ADDED
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Skill
6
+ # The +skill+ command line.
7
+ class CLI
8
+ COMMANDS = {
9
+ 'add' => :Add, 'install' => :Add, 'a' => :Add, 'i' => :Add,
10
+ 'list' => :List, 'ls' => :List,
11
+ 'update' => :Update, 'upgrade' => :Update, 'up' => :Update,
12
+ 'remove' => :Remove, 'rm' => :Remove, 'uninstall' => :Remove,
13
+ 'find' => :Find, 'search' => :Find, 's' => :Find,
14
+ 'agents' => :Agents
15
+ }.freeze
16
+
17
+ USAGE = <<~TEXT
18
+ Usage: skill <command> [options]
19
+
20
+ Commands:
21
+ add <source> Install skills from GitHub or a local folder
22
+ list List installed skills
23
+ update [names] Update installed skills, showing what changes first
24
+ remove <names> Remove installed skills
25
+ find <query> Search the skills.sh directory
26
+ agents List the agents and folders skills install to
27
+
28
+ Sources:
29
+ owner/repo a repository (asks which skills when it holds several)
30
+ owner/repo@pdf one skill by name
31
+ owner/repo/skills/pdf one folder of a repository
32
+ owner/repo#v1.2.0 a branch, tag, or full commit SHA
33
+ https://github.com/owner/repo/tree/main/skills/pdf
34
+ ./path/to/skills a local folder
35
+
36
+ Run `skill <command> --help` for a command's options.
37
+ TEXT
38
+
39
+ def self.start(argv)
40
+ exit new(argv).run
41
+ end
42
+
43
+ def initialize(argv, out: $stdout, err: $stderr, input: $stdin, env: ENV, cwd: Dir.pwd, home: Dir.home,
44
+ http: nil)
45
+ @argv = argv.dup
46
+ @session = Session.new(out: out, err: err, input: input, env: env, cwd: cwd, home: home, http: http)
47
+ end
48
+
49
+ # Runs the command and returns its exit status: 0 on success, 1 when it
50
+ # failed, 2 for a malformed command line.
51
+ def run
52
+ name = @argv.shift
53
+ case name
54
+ when nil, 'help', '-h', '--help' then help
55
+ when 'version', '-v', '--version' then version
56
+ else command(name).run
57
+ end
58
+ rescue OptionParser::ParseError, UsageError => e
59
+ fail_with(e, 2)
60
+ rescue Error => e
61
+ fail_with(e, 1)
62
+ rescue Interrupt
63
+ 130
64
+ ensure
65
+ @session.cleanup
66
+ end
67
+
68
+ private
69
+
70
+ def command(name)
71
+ constant = COMMANDS.fetch(name) { raise UsageError, "Unknown command #{name.inspect}. Run `skill help`" }
72
+ Commands.const_get(constant).new(@session, @argv)
73
+ end
74
+
75
+ def help
76
+ @session.say USAGE
77
+ 0
78
+ end
79
+
80
+ def version
81
+ @session.say "skill #{VERSION}"
82
+ 0
83
+ end
84
+
85
+ def fail_with(error, status)
86
+ @session.error(error.message)
87
+ status
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # skill add <source>: installs skills from a repository or folder.
6
+ class Add < Command
7
+ def run
8
+ arguments = parse('add <source> [options]') { |opts| define(opts) }
9
+ raise UsageError, "Name one source.\n\n#{usage}" unless arguments.size == 1
10
+
11
+ source = Source.parse(arguments.first, root: session.cwd, ref: options[:ref])
12
+ checkout = session.fetcher.checkout(source)
13
+ discovery = Discovery.new(checkout.root, subpath: source.subpath, full_depth: options[:full_depth])
14
+ discovery.warnings.each { |warning| warn(warning) }
15
+ return list(checkout, discovery) if options[:list]
16
+
17
+ install(checkout, Selection.new(session, checkout, discovery, options).skills)
18
+ end
19
+
20
+ private
21
+
22
+ def define(opts)
23
+ options.merge!(skills: [], plugins: [])
24
+ opts.on('-s', '--skill NAME', 'Install this skill (repeatable, comma-separated, * for all)') do |value|
25
+ options[:skills].concat(split(value))
26
+ end
27
+ opts.on('--plugin NAME', 'Install the skills of this marketplace plugin') do |value|
28
+ options[:plugins].concat(split(value))
29
+ end
30
+ opts.on('--all', 'Install every valid skill the source holds') { options[:all] = true }
31
+ opts.on('-l', '--list', 'List the skills the source holds, without installing') { options[:list] = true }
32
+ opts.on('--ref REF', 'Branch, tag, or full commit SHA to install from') { |value| options[:ref] = value }
33
+ opts.on('-x', '--keep-executable', 'Keep executable bits on scripts') { options[:executable] = true }
34
+ opts.on('-f', '--force', 'Replace skills this command did not install, or that were edited') do
35
+ options[:force] = true
36
+ end
37
+ opts.on('--full-depth', 'Also search the whole repository, beyond the usual folders') do
38
+ options[:full_depth] = true
39
+ end
40
+ end
41
+
42
+ def install(checkout, candidates)
43
+ copies = candidates.map { |candidate| copy(checkout, candidate) }
44
+ executable = options[:executable] ? true : false
45
+ warn_executables(copies) unless executable
46
+ Installer.new.install(copies)
47
+ copies.each { |copy| workspace.lock[copy.name] = copy.entry }
48
+ workspace.lock.save
49
+ report(copies)
50
+ end
51
+
52
+ def report(copies)
53
+ scope = workspace.global? ? style.dim(' (global)') : ''
54
+ copies.each { |copy| say done("Installed #{style.bold(copy.name)} → #{copy.labels.join(', ')}#{scope}") }
55
+ say style.dim(" Recorded in #{workspace.display(workspace.lock.path)}")
56
+ end
57
+
58
+ def copy(checkout, candidate)
59
+ refuse_links(checkout, candidate)
60
+ name = candidate.manifest.name
61
+ tree = Tree.read(candidate.path, limits: session.limits)
62
+ folders = folders_for(name, checkout, candidate)
63
+ folders.each { |folder| workspace.ensure_replaceable(name, folder, options[:force]) }
64
+ Installer::Copy.new(name: name, tree: tree, targets: folders, executable: options[:executable] ? true : false,
65
+ entry: entry(checkout, candidate, tree, folders),
66
+ labels: folders.map { |folder| workspace.display(File.join(folder, name)) })
67
+ end
68
+
69
+ # Returns the folders to install into: those asked for, plus those an
70
+ # earlier install from the same source recorded, so they stay alike.
71
+ def folders_for(name, checkout, candidate)
72
+ existing = workspace.lock[name]
73
+ same = existing && existing['source'] == workspace.source_label(checkout.source)
74
+ if existing && !(same && existing['path'] == candidate.relative) && !options[:force]
75
+ raise ConflictError,
76
+ "#{name} is installed from #{Workspace.describe(existing)}. Remove it first, or pass --force"
77
+ end
78
+
79
+ kept = same ? workspace.folders(existing) : []
80
+ (kept + workspace.resolve(agents: options[:agents], dirs: options[:dirs])).uniq
81
+ end
82
+
83
+ def refuse_links(checkout, candidate)
84
+ return unless checkout.link_inside?(candidate.relative)
85
+
86
+ raise UnsafeError, "#{candidate.relative} holds a symbolic link; skills may not hold links"
87
+ end
88
+
89
+ def entry(checkout, candidate, tree, folders)
90
+ executable = options[:executable] ? true : false
91
+ resolution = checkout.resolution
92
+ {
93
+ 'source' => workspace.source_label(checkout.source),
94
+ 'ref' => resolution&.name,
95
+ 'ref_type' => resolution ? resolution.type.to_s : 'local',
96
+ 'commit' => checkout.commit,
97
+ 'path' => candidate.relative,
98
+ 'plugin' => candidate.plugin,
99
+ 'digest' => tree.digest(executable: executable),
100
+ 'executable' => executable,
101
+ 'targets' => folders.map { |folder| workspace.display(folder) }
102
+ }
103
+ end
104
+
105
+ def warn_executables(copies)
106
+ copies.each do |copy|
107
+ files = copy.tree.executables
108
+ next if files.empty?
109
+
110
+ count = files.size == 1 ? '1 file' : "#{files.size} files"
111
+ warn("#{copy.name}: dropped the executable bit from #{count} (use --keep-executable)")
112
+ end
113
+ end
114
+
115
+ def list(checkout, discovery)
116
+ say "#{checkout.source}#{" at #{checkout.commit[0, 12]}" if checkout.commit}:"
117
+ say ' (no skills)' if discovery.candidates.empty?
118
+ discovery.candidates.each do |candidate|
119
+ plugin = candidate.plugin ? " [#{candidate.plugin}]" : ''
120
+ detail = if candidate.valid?
121
+ style.dim(truncate(candidate.manifest.description))
122
+ else
123
+ style.yellow("INVALID: #{candidate.error}")
124
+ end
125
+ say " #{style.bold(candidate.name)}#{style.dim(plugin)} #{detail}"
126
+ end
127
+ discovery.external.each do |plugin|
128
+ say " [#{plugin['plugin']}] lives in #{plugin['source']}: run `skill add #{plugin['source']}`"
129
+ end
130
+ 0
131
+ end
132
+
133
+ def truncate(text, length = 90)
134
+ line = text.to_s.gsub(/\s+/, ' ')
135
+ line.length > length ? "#{line[0, length - 1]}…" : line
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # skill agents: the targets skills install to.
6
+ class Agents < Command
7
+ def run
8
+ parse('agents', scope: false)
9
+ width = Skill::Agents.all.map { |agent| agent.key.length }.max
10
+ Skill::Agents.all.each do |agent|
11
+ global = agent.global_dir&.sub(/\A\{(\w+)\}/) { Skill::Agents::HOMES.fetch(Regexp.last_match(1)).last }
12
+ global ||= '(project only)'
13
+ say "#{agent.key.ljust(width)} #{agent.project_dir.ljust(26)} #{global.ljust(34)} #{agent.name}"
14
+ end
15
+ 0
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Skill
6
+ module Commands
7
+ # A subcommand: parses its options, then #run returns the exit status.
8
+ class Command
9
+ def initialize(session, argv)
10
+ @session = session
11
+ @argv = argv
12
+ @options = { agents: [], dirs: [] }
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :session, :options
18
+
19
+ # Parses the arguments with the options +define+ adds, plus the scope
20
+ # options (+--global+, +--project+, +--agent+, +--dir+) unless +scope+
21
+ # is false.
22
+ # Returns the arguments left.
23
+ def parse(banner, scope: true, &define)
24
+ @parser = OptionParser.new do |opts|
25
+ opts.banner = "Usage: skill #{banner}"
26
+ scope_options(opts) if scope
27
+ define&.call(opts)
28
+ end
29
+ @parser.parse(@argv)
30
+ end
31
+
32
+ def usage
33
+ @parser.to_s
34
+ end
35
+
36
+ def scope_options(opts)
37
+ opts.on('-g', '--global', 'Use the home folder, even inside a project') { options[:global] = true }
38
+ opts.on('-p', '--project', 'Use the current folder as a project, even outside one') do
39
+ options[:global] = false
40
+ end
41
+ opts.on('-a', '--agent NAME', 'Target this agent (repeatable, comma-separated, * for all)') do |value|
42
+ options[:agents].concat(split(value))
43
+ end
44
+ opts.on('-d', '--dir PATH', 'Target this folder (repeatable)') do |value|
45
+ options[:dirs] << File.expand_path(value, session.cwd)
46
+ end
47
+ end
48
+
49
+ def workspace
50
+ session.workspace(global: options[:global])
51
+ end
52
+
53
+ # Returns the target folders the scope options name, or +nil+ when
54
+ # they name none.
55
+ def chosen_folders
56
+ return if options[:agents].empty? && options[:dirs].empty?
57
+
58
+ workspace.resolve(agents: options[:agents], dirs: options[:dirs])
59
+ end
60
+
61
+ def style
62
+ session.style
63
+ end
64
+
65
+ # Returns "✓ " and +text+, the check green on a terminal.
66
+ def done(text)
67
+ "#{style.green('✓')} #{text}"
68
+ end
69
+
70
+ def say(text = '')
71
+ session.say(text)
72
+ 0
73
+ end
74
+
75
+ def warn(text)
76
+ session.warn(text)
77
+ end
78
+
79
+ def split(value)
80
+ value.split(',').map(&:strip).reject(&:empty?)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # skill find <query>: searches the skills.sh directory.
6
+ class Find < Command
7
+ def run
8
+ options[:limit] = 20
9
+ query = parse('find <query> [options]', scope: false) do |opts|
10
+ opts.on('--owner OWNER', 'Only skills from this GitHub owner') { |value| options[:owner] = value }
11
+ opts.on('--limit N', Integer, 'Show at most N results (default 20)') { |value| options[:limit] = value }
12
+ end.join(' ').strip
13
+ raise UsageError, "Name what to search for.\n\n#{usage}" if query.empty?
14
+
15
+ owner = options[:owner]
16
+ raise UsageError, "#{owner} is not a GitHub owner" if owner && !owner.match?(Source::OWNER)
17
+
18
+ show(query, Directory.new(http: session.http, env: session.env)
19
+ .search(query, owner: options[:owner], limit: options[:limit]))
20
+ end
21
+
22
+ private
23
+
24
+ def show(query, results)
25
+ return say("No skills found for #{query.inspect}.") if results.empty?
26
+
27
+ rows = results.map { |result| [result.name, result.source, installs(result.installs), result.command] }
28
+ width, sources, counts = (0..2).map { |column| rows.map { |row| row[column].length }.max }
29
+ rows.each do |name, source, count, command|
30
+ line = "#{style.bold(name.ljust(width))} #{style.dim(source.ljust(sources))}"
31
+ line += " #{style.dim(count.rjust(counts))}" unless count.empty?
32
+ say line.rstrip
33
+ say "#{' ' * width} #{style.dim(command)}"
34
+ end
35
+ 0
36
+ end
37
+
38
+ def installs(count)
39
+ return '' unless count.positive?
40
+ return "#{compact(count / 1_000_000.0)}M installs" if count >= 1_000_000
41
+ return "#{compact(count / 1000.0)}K installs" if count >= 1000
42
+
43
+ "#{count} install#{'s' unless count == 1}"
44
+ end
45
+
46
+ def compact(number)
47
+ number.round(1).to_s.delete_suffix('.0')
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # skill list: the skills in the lockfile, whether each is still as
6
+ # installed, and the skill folders this command did not install.
7
+ class List < Command
8
+ def run
9
+ parse('list [options]') { |opts| opts.on('--json', 'Print JSON') { options[:json] = true } }
10
+ rows = workspace.lock.skills.map { |name, entry| [name, entry, workspace.status(name, entry)] }
11
+ if chosen_folders
12
+ rows.select! { |_name, entry, _status| workspace.recorded_folders(entry).intersect?(chosen_folders) }
13
+ end
14
+ return json(rows) if options[:json]
15
+
16
+ installed(rows)
17
+ unmanaged
18
+ 0
19
+ end
20
+
21
+ private
22
+
23
+ def installed(rows)
24
+ return say("No skills installed with skill in #{workspace.display(workspace.root)}.") if rows.empty?
25
+
26
+ table = rows.map do |name, entry, state|
27
+ [name, source(entry), version(entry), entry['targets'].join(', '), state == 'ok' ? '' : state]
28
+ end
29
+ widths = (0..3).map { |column| table.map { |row| row[column].length }.max }
30
+ table.each { |row| say row_line(row, widths) }
31
+ end
32
+
33
+ def row_line(row, widths)
34
+ name, source, version, targets, state = row.each_with_index.map do |cell, column|
35
+ column < 4 && column != 3 ? cell.ljust(widths[column]) : cell
36
+ end
37
+ line = "#{style.bold(name)} #{style.dim(source)} #{style.dim(version)} #{targets}"
38
+ line += " #{style.yellow(state)}" unless state.empty?
39
+ line
40
+ end
41
+
42
+ def source(entry)
43
+ entry['ref'] ? "#{entry['source']}##{entry['ref']}" : entry['source']
44
+ end
45
+
46
+ def version(entry)
47
+ entry['commit'] ? "#{entry['ref_type']} #{entry['commit'][0, 12]}" : entry['ref_type'].to_s
48
+ end
49
+
50
+ def unmanaged
51
+ managed = workspace.lock.names
52
+ folders = workspace.targets.existing.flat_map do |target|
53
+ Dir.children(target.path).sort.filter_map do |child|
54
+ path = File.join(target.path, child)
55
+ next if child.start_with?('.') || managed.include?(child) || !File.file?(File.join(path, Manifest::FILE))
56
+
57
+ workspace.display(path)
58
+ end
59
+ end
60
+ return if folders.empty?
61
+
62
+ say
63
+ say style.dim('Not installed by skill:')
64
+ folders.each { |folder| say " #{folder}" }
65
+ end
66
+
67
+ def json(rows)
68
+ require 'json'
69
+ say JSON.pretty_generate(rows.to_h { |name, entry, state| [name, entry.merge('status' => state)] })
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # skill remove <names>: deletes skills this command installed, from
6
+ # every folder it recorded, or only those +--agent+ and +--dir+ name.
7
+ class Remove < Command
8
+ def run
9
+ names = parse('remove <names> [options]') do |opts|
10
+ opts.on('--all', 'Remove every installed skill') { options[:all] = true }
11
+ opts.on('-f', '--force', 'Remove skills that were edited since they were installed') do
12
+ options[:force] = true
13
+ end
14
+ end
15
+ names = workspace.lock.names if options[:all]
16
+ raise UsageError, "Name the skills to remove, or pass --all.\n\n#{usage}" if names.empty?
17
+
18
+ plan = names.to_h { |name| [name, folders(name)] }
19
+ plan.each { |name, folders| remove(name, folders) }
20
+ workspace.lock.save
21
+ 0
22
+ end
23
+
24
+ private
25
+
26
+ # Returns the folders to remove +name+ from, after checking each still
27
+ # holds what was installed.
28
+ def folders(name)
29
+ entry = workspace.lock[name]
30
+ refuse_unknown(name) unless entry
31
+ recorded = workspace.folders(entry)
32
+ folders = chosen_folders ? recorded & chosen_folders : recorded
33
+ folders.each { |folder| workspace.ensure_unedited(name, folder, options[:force]) }
34
+ folders
35
+ end
36
+
37
+ def refuse_unknown(name)
38
+ found = workspace.targets.existing.map { |target| Installer.destination(target.path, name) }
39
+ .find { |path| File.exist?(path) }
40
+ raise NotFoundError, "#{name} is not installed" unless found
41
+
42
+ raise ConflictError, "#{workspace.display(found)} was not installed by skill. Delete it yourself"
43
+ end
44
+
45
+ def remove(name, folders)
46
+ entry = workspace.lock[name]
47
+ Installer.new.remove(name, folders)
48
+ remaining = entry['targets'].reject { |path| folders.include?(workspace.targets.expand(path)) }
49
+ if remaining.empty?
50
+ workspace.lock.delete(name)
51
+ else
52
+ workspace.lock[name] = entry.merge('targets' => remaining)
53
+ end
54
+ say done("Removed #{style.bold(name)} from " \
55
+ "#{folders.map { |folder| workspace.display(File.join(folder, name)) }.join(', ')}")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skill
4
+ module Commands
5
+ # Which of a source's skills +skill add+ installs: the ones named after
6
+ # +@+ or with +--skill+, a plugin's with +--plugin+, every valid one with
7
+ # +--all+, or the only one there is. Anything else asks for a choice.
8
+ class Selection
9
+ def initialize(session, checkout, discovery, options)
10
+ @session = session
11
+ @source = checkout.source
12
+ @candidates = discovery.candidates
13
+ @external = discovery.external
14
+ @names = @source.skills + options[:skills]
15
+ @plugins = options[:plugins]
16
+ @all = options[:all] || @names.include?('*')
17
+ end
18
+
19
+ # Returns the chosen Discovery::Candidates. Raises when a named skill
20
+ # is missing or invalid, or when the choice is unclear.
21
+ def skills
22
+ raise NotFoundError, "#{@source} holds no skills#{external_hint}" if @candidates.empty?
23
+
24
+ chosen = if @all then every
25
+ elsif @names.any? || @plugins.any? then named + plugins
26
+ else only
27
+ end
28
+ chosen.uniq(&:relative)
29
+ end
30
+
31
+ private
32
+
33
+ def every
34
+ @candidates.reject(&:valid?).each do |candidate|
35
+ @session.warn("Skipping #{candidate.relative}: #{candidate.error}")
36
+ end
37
+ chosen = @candidates.select(&:valid?).reject { |candidate| hidden?(candidate) }
38
+ raise InvalidSkillError, "None of the skills in #{@source} is valid" if chosen.empty?
39
+
40
+ chosen
41
+ end
42
+
43
+ def named
44
+ @names.map do |name|
45
+ candidate = @candidates.find { |each| each.name == name }
46
+ raise NotFoundError, "No skill named #{name}. Skills: #{@candidates.map(&:name).join(', ')}" unless candidate
47
+ raise InvalidSkillError, candidate.error unless candidate.valid?
48
+
49
+ candidate
50
+ end
51
+ end
52
+
53
+ def plugins
54
+ @plugins.flat_map do |name|
55
+ found = @candidates.select { |candidate| candidate.plugin == name && candidate.valid? }
56
+ raise NotFoundError, "No plugin named #{name} with valid skills" if found.empty?
57
+
58
+ found
59
+ end
60
+ end
61
+
62
+ def only
63
+ if @candidates.size == 1
64
+ raise InvalidSkillError, @candidates.first.error unless @candidates.first.valid?
65
+
66
+ return @candidates
67
+ end
68
+
69
+ raise UsageError, <<~TEXT.chomp
70
+ #{@source} holds #{@candidates.size} skills: #{@candidates.map(&:name).join(', ')}
71
+ Pick with #{@source.name}@NAME, --skill NAME, --plugin NAME, or --all. See them with --list.
72
+ TEXT
73
+ end
74
+
75
+ def hidden?(candidate)
76
+ candidate.manifest.internal? && !@session.internal_skills?
77
+ end
78
+
79
+ def external_hint
80
+ return '' if @external.empty?
81
+
82
+ sources = @external.map { |plugin| plugin['source'] }
83
+ ". Its marketplace lists plugins from other repositories: #{sources.join(', ')}"
84
+ end
85
+ end
86
+ end
87
+ end