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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +280 -0
- data/exe/skill +7 -0
- data/lib/skill/agents.rb +148 -0
- data/lib/skill/archive.rb +143 -0
- data/lib/skill/boundary.rb +42 -0
- data/lib/skill/cli.rb +90 -0
- data/lib/skill/commands/add.rb +139 -0
- data/lib/skill/commands/agents.rb +19 -0
- data/lib/skill/commands/command.rb +84 -0
- data/lib/skill/commands/find.rb +51 -0
- data/lib/skill/commands/list.rb +73 -0
- data/lib/skill/commands/remove.rb +59 -0
- data/lib/skill/commands/selection.rb +87 -0
- data/lib/skill/commands/update.rb +175 -0
- data/lib/skill/commands.rb +15 -0
- data/lib/skill/directory.rb +59 -0
- data/lib/skill/discovery.rb +166 -0
- data/lib/skill/fetcher.rb +62 -0
- data/lib/skill/github.rb +158 -0
- data/lib/skill/http.rb +76 -0
- data/lib/skill/installer.rb +92 -0
- data/lib/skill/limits.rb +40 -0
- data/lib/skill/lockfile.rb +112 -0
- data/lib/skill/manifest.rb +103 -0
- data/lib/skill/session.rb +88 -0
- data/lib/skill/source.rb +137 -0
- data/lib/skill/style.rb +28 -0
- data/lib/skill/targets.rb +173 -0
- data/lib/skill/tree.rb +90 -0
- data/lib/skill/version.rb +5 -0
- data/lib/skill/workspace.rb +161 -0
- data/lib/skill.rb +60 -0
- metadata +84 -0
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
|