shai-cli 0.1.1 → 0.3.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/README.md +109 -15
- data/lib/shai/api_client.rb +49 -0
- data/lib/shai/cli.rb +13 -3
- data/lib/shai/commands/auth.rb +154 -26
- data/lib/shai/commands/configurations.rb +318 -133
- data/lib/shai/commands/skills.rb +199 -0
- data/lib/shai/install_registry.rb +62 -0
- data/lib/shai/installed_projects.rb +163 -0
- data/lib/shai/skill_scanner.rb +99 -0
- data/lib/shai/version.rb +1 -1
- data/lib/shai.rb +38 -0
- metadata +20 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shai
|
|
4
|
+
module Commands
|
|
5
|
+
module Skills
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.class_eval do
|
|
8
|
+
desc "skills [SUBCOMMAND]", "Manage AI agent skills"
|
|
9
|
+
method_option :global, type: :boolean, default: false, desc: "Target global skills"
|
|
10
|
+
method_option :local, type: :boolean, default: false, desc: "Target local skills"
|
|
11
|
+
method_option :agent, type: :string, desc: "Target a specific agent (e.g. claude, codex)"
|
|
12
|
+
def skills(subcommand = nil, *args)
|
|
13
|
+
case subcommand
|
|
14
|
+
when nil, "list"
|
|
15
|
+
skills_list
|
|
16
|
+
when "enable"
|
|
17
|
+
name = args.first
|
|
18
|
+
unless name
|
|
19
|
+
ui.error("Usage: shai skills enable <name> [--global|--local] [--agent <agent>]")
|
|
20
|
+
exit EXIT_INVALID_INPUT
|
|
21
|
+
end
|
|
22
|
+
skills_enable(name)
|
|
23
|
+
when "disable"
|
|
24
|
+
name = args.first
|
|
25
|
+
unless name
|
|
26
|
+
ui.error("Usage: shai skills disable <name> [--global|--local] [--agent <agent>]")
|
|
27
|
+
exit EXIT_INVALID_INPUT
|
|
28
|
+
end
|
|
29
|
+
skills_disable(name)
|
|
30
|
+
else
|
|
31
|
+
ui.error("Unknown subcommand: #{subcommand}. Use `shai skills`, `shai skills enable`, or `shai skills disable`")
|
|
32
|
+
exit EXIT_INVALID_INPUT
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def skills_list
|
|
41
|
+
scanner = SkillScanner.new
|
|
42
|
+
all_skills = scanner.scan_all
|
|
43
|
+
populate_sources!(all_skills)
|
|
44
|
+
|
|
45
|
+
if all_skills.empty?
|
|
46
|
+
ui.info("No skills found.")
|
|
47
|
+
ui.blank
|
|
48
|
+
ui.info("Skill discovery paths:")
|
|
49
|
+
SkillScanner::AGENTS.each do |agent|
|
|
50
|
+
ui.info(" Global: ~/#{agent[:skill_dir]}/*/SKILL.md (#{agent[:name]})")
|
|
51
|
+
ui.info(" Local: ./#{agent[:skill_dir]}/*/SKILL.md (#{agent[:name]})")
|
|
52
|
+
end
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
global_skills = all_skills.select { |s| s.scope == :global }
|
|
57
|
+
local_skills = all_skills.select { |s| s.scope == :local }
|
|
58
|
+
|
|
59
|
+
if global_skills.any?
|
|
60
|
+
ui.blank
|
|
61
|
+
ui.header("Global skills")
|
|
62
|
+
ui.blank
|
|
63
|
+
ui.table(
|
|
64
|
+
["Skill", "Status", "Agent", "Source"],
|
|
65
|
+
global_skills.map { |s| skill_row(s) }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if local_skills.any?
|
|
70
|
+
ui.blank
|
|
71
|
+
ui.header("Local skills")
|
|
72
|
+
ui.blank
|
|
73
|
+
ui.table(
|
|
74
|
+
["Skill", "Status", "Agent", "Source"],
|
|
75
|
+
local_skills.map { |s| skill_row(s) }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
enabled_count = all_skills.count(&:enabled)
|
|
80
|
+
disabled_count = all_skills.count { |s| !s.enabled }
|
|
81
|
+
ui.blank
|
|
82
|
+
ui.info("#{enabled_count} enabled, #{disabled_count} disabled")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skills_enable(name)
|
|
86
|
+
scanner, skill = resolve_skill(scanner_instance, name)
|
|
87
|
+
return unless skill
|
|
88
|
+
|
|
89
|
+
if skill.enabled
|
|
90
|
+
ui.info("Skill '#{name}' is already enabled (#{skill.scope}, #{skill.agent})")
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
scanner.enable!(skill)
|
|
95
|
+
ui.success("Enabled skill '#{name}' (#{skill.scope}, #{skill.agent})")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def skills_disable(name)
|
|
99
|
+
scanner, skill = resolve_skill(scanner_instance, name)
|
|
100
|
+
return unless skill
|
|
101
|
+
|
|
102
|
+
unless skill.enabled
|
|
103
|
+
ui.info("Skill '#{name}' is already disabled (#{skill.scope}, #{skill.agent})")
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
scanner.disable!(skill)
|
|
108
|
+
ui.success("Disabled skill '#{name}' (#{skill.scope}, #{skill.agent})")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def scanner_instance
|
|
112
|
+
SkillScanner.new
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_skill(scanner, name)
|
|
116
|
+
validate_scope_flags!
|
|
117
|
+
validate_agent_flag!
|
|
118
|
+
|
|
119
|
+
scope = if options[:global]
|
|
120
|
+
:global
|
|
121
|
+
elsif options[:local]
|
|
122
|
+
:local
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
agent = options[:agent]
|
|
126
|
+
|
|
127
|
+
matches = scanner.find(name, scope: scope, agent: agent)
|
|
128
|
+
|
|
129
|
+
if matches.empty?
|
|
130
|
+
ui.error("Skill '#{name}' not found. Run `shai skills` to see available skills.")
|
|
131
|
+
exit EXIT_NOT_FOUND
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if matches.length > 1
|
|
135
|
+
ambiguous_by_scope = matches.map(&:scope).uniq.length > 1
|
|
136
|
+
ambiguous_by_agent = matches.map(&:agent).uniq.length > 1
|
|
137
|
+
|
|
138
|
+
ui.error("Skill '#{name}' exists in multiple #{ambiguous_by_agent ? "agents" : "scopes"}:")
|
|
139
|
+
matches.each { |s| ui.info(" - #{s.scope}, #{s.agent} (#{s.path})") }
|
|
140
|
+
|
|
141
|
+
hints = []
|
|
142
|
+
hints << "--global or --local" if ambiguous_by_scope
|
|
143
|
+
hints << "--agent <agent>" if ambiguous_by_agent
|
|
144
|
+
ui.info("Use #{hints.join(" and ")} to specify which one.")
|
|
145
|
+
exit EXIT_INVALID_INPUT
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
[scanner, matches.first]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_scope_flags!
|
|
152
|
+
if options[:global] && options[:local]
|
|
153
|
+
ui.error("Cannot use --global and --local together")
|
|
154
|
+
exit EXIT_INVALID_INPUT
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def validate_agent_flag!
|
|
159
|
+
agent = options[:agent]
|
|
160
|
+
return unless agent
|
|
161
|
+
|
|
162
|
+
known = SkillScanner::AGENTS.map { |a| a[:name] }
|
|
163
|
+
unless known.include?(agent)
|
|
164
|
+
ui.error("Unknown agent '#{agent}'. Known agents: #{known.join(", ")}")
|
|
165
|
+
exit EXIT_INVALID_INPUT
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def skill_row(skill)
|
|
170
|
+
status = skill.enabled ? "enabled" : "disabled"
|
|
171
|
+
source = skill.source || "-"
|
|
172
|
+
[skill.name, status, skill.agent, source]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def populate_sources!(skills)
|
|
176
|
+
[Dir.home, Dir.pwd].each do |base|
|
|
177
|
+
installed = InstalledProjects.new(base)
|
|
178
|
+
next if installed.empty?
|
|
179
|
+
|
|
180
|
+
skills.each do |skill|
|
|
181
|
+
next if skill.source
|
|
182
|
+
|
|
183
|
+
installed.project_slugs.each do |slug|
|
|
184
|
+
files = installed.files_for_project(slug)
|
|
185
|
+
SkillScanner::AGENTS.each do |agent|
|
|
186
|
+
skill_relative = "#{agent[:skill_dir]}/#{skill.name}/SKILL.md"
|
|
187
|
+
if files.any? { |f| f == skill_relative || f.start_with?("#{agent[:skill_dir]}/#{skill.name}/") }
|
|
188
|
+
skill.source = slug
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
break if skill.source
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Shai
|
|
8
|
+
class InstallRegistry
|
|
9
|
+
def initialize
|
|
10
|
+
@file_path = File.join(Shai.configuration.config_dir, "installations.yml")
|
|
11
|
+
@data = load_data
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :file_path
|
|
15
|
+
|
|
16
|
+
def add(slug, path)
|
|
17
|
+
@data["projects"][slug] = {
|
|
18
|
+
"path" => File.expand_path(path),
|
|
19
|
+
"installed_at" => Time.now.iso8601
|
|
20
|
+
}
|
|
21
|
+
save!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remove(slug)
|
|
25
|
+
removed = @data["projects"].delete(slug)
|
|
26
|
+
save! if removed
|
|
27
|
+
removed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def path_for(slug)
|
|
31
|
+
@data.dig("projects", slug, "path")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all
|
|
35
|
+
@data["projects"].dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def has?(slug)
|
|
39
|
+
@data["projects"].key?(slug)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def load_data
|
|
45
|
+
return default_data unless File.exist?(@file_path)
|
|
46
|
+
|
|
47
|
+
raw = YAML.safe_load_file(@file_path) || {}
|
|
48
|
+
(raw.is_a?(Hash) && raw["projects"].is_a?(Hash)) ? raw : default_data
|
|
49
|
+
rescue
|
|
50
|
+
default_data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def default_data
|
|
54
|
+
{"projects" => {}}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def save!
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(@file_path))
|
|
59
|
+
File.write(@file_path, YAML.dump(@data))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Shai
|
|
7
|
+
class InstalledProjects
|
|
8
|
+
FILENAME = ".shai-installed"
|
|
9
|
+
CURRENT_VERSION = 2
|
|
10
|
+
|
|
11
|
+
attr_reader :base_path
|
|
12
|
+
|
|
13
|
+
def initialize(base_path)
|
|
14
|
+
@base_path = File.expand_path(base_path)
|
|
15
|
+
@data = load_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def file_path
|
|
19
|
+
File.join(base_path, FILENAME)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exists?
|
|
23
|
+
File.exist?(file_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def projects
|
|
27
|
+
@data["projects"] || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def project_slugs
|
|
31
|
+
projects.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
projects.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def project_count
|
|
39
|
+
projects.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def has_project?(slug)
|
|
43
|
+
projects.key?(slug)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_project(slug)
|
|
47
|
+
projects[slug]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def files_for_project(slug)
|
|
51
|
+
projects.dig(slug, "files") || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def all_installed_files
|
|
55
|
+
projects.values.flat_map { |p| p["files"] || [] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Find which project owns a specific file
|
|
59
|
+
def project_for_file(file_path)
|
|
60
|
+
projects.each do |slug, data|
|
|
61
|
+
return slug if (data["files"] || []).include?(file_path)
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check for conflicts between new files and already installed projects
|
|
67
|
+
# Returns hash of { file_path => owning_project_slug }
|
|
68
|
+
def find_conflicts(new_files)
|
|
69
|
+
conflicts = {}
|
|
70
|
+
new_files.each do |file|
|
|
71
|
+
owner = project_for_file(file)
|
|
72
|
+
conflicts[file] = owner if owner
|
|
73
|
+
end
|
|
74
|
+
conflicts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Add a new project with its files
|
|
78
|
+
def add_project(slug, files)
|
|
79
|
+
@data["projects"][slug] = {
|
|
80
|
+
"installed_at" => Time.now.iso8601,
|
|
81
|
+
"files" => files.sort
|
|
82
|
+
}
|
|
83
|
+
save!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Remove a project and return its files
|
|
87
|
+
def remove_project(slug)
|
|
88
|
+
project = @data["projects"].delete(slug)
|
|
89
|
+
save! if project
|
|
90
|
+
project&.dig("files") || []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Remove specific files from a project (when being overwritten)
|
|
94
|
+
def remove_files_from_project(slug, files_to_remove)
|
|
95
|
+
return unless @data["projects"][slug]
|
|
96
|
+
|
|
97
|
+
current_files = @data["projects"][slug]["files"] || []
|
|
98
|
+
@data["projects"][slug]["files"] = current_files - files_to_remove
|
|
99
|
+
|
|
100
|
+
# If no files left, remove the project entirely
|
|
101
|
+
if @data["projects"][slug]["files"].empty?
|
|
102
|
+
@data["projects"].delete(slug)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
save!
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def save!
|
|
109
|
+
content = YAML.dump(@data)
|
|
110
|
+
header = "# Installed by shai - do not edit manually\n"
|
|
111
|
+
File.write(file_path, header + content)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def delete!
|
|
115
|
+
File.delete(file_path) if exists?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def load_data
|
|
121
|
+
return default_data unless exists?
|
|
122
|
+
|
|
123
|
+
raw = YAML.safe_load_file(file_path) || {}
|
|
124
|
+
|
|
125
|
+
# Handle old format (version 1 / no version)
|
|
126
|
+
if raw["version"].nil? || raw["version"] < CURRENT_VERSION
|
|
127
|
+
migrate_from_v1(raw)
|
|
128
|
+
else
|
|
129
|
+
raw
|
|
130
|
+
end
|
|
131
|
+
rescue
|
|
132
|
+
# If file is corrupted, start fresh
|
|
133
|
+
default_data
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def default_data
|
|
137
|
+
{
|
|
138
|
+
"version" => CURRENT_VERSION,
|
|
139
|
+
"projects" => {}
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def migrate_from_v1(old_data)
|
|
144
|
+
# Old format had: { "slug" => "name", "installed_at" => "..." }
|
|
145
|
+
slug = old_data["slug"]
|
|
146
|
+
installed_at = old_data["installed_at"]
|
|
147
|
+
|
|
148
|
+
if slug
|
|
149
|
+
{
|
|
150
|
+
"version" => CURRENT_VERSION,
|
|
151
|
+
"projects" => {
|
|
152
|
+
slug => {
|
|
153
|
+
"installed_at" => installed_at || Time.now.iso8601,
|
|
154
|
+
"files" => [] # We don't know the files from v1, will be populated on next operation
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
default_data
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shai
|
|
4
|
+
Skill = Struct.new(:name, :scope, :enabled, :path, :source, :agent, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
class SkillScanner
|
|
7
|
+
SKILL_FILE = "SKILL.md"
|
|
8
|
+
DISABLED_SUFFIX = ".disabled"
|
|
9
|
+
|
|
10
|
+
AGENTS = [
|
|
11
|
+
{name: "claude", skill_dir: ".claude/skills"},
|
|
12
|
+
{name: "codex", skill_dir: ".agents/skills"}
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :local_base, :global_base
|
|
16
|
+
|
|
17
|
+
def initialize(local_base: Dir.pwd, global_base: Dir.home)
|
|
18
|
+
@local_base = File.expand_path(local_base)
|
|
19
|
+
@global_base = File.expand_path(global_base)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def scan_all
|
|
23
|
+
skills = []
|
|
24
|
+
skills.concat(scan_scope(:global))
|
|
25
|
+
skills.concat(scan_scope(:local))
|
|
26
|
+
skills
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find(name, scope: nil, agent: nil)
|
|
30
|
+
skills = scan_all
|
|
31
|
+
skills.select! { |s| s.name == name }
|
|
32
|
+
skills.select! { |s| s.scope == scope } if scope
|
|
33
|
+
skills.select! { |s| s.agent == agent } if agent
|
|
34
|
+
skills
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enable!(skill)
|
|
38
|
+
return false if skill.enabled
|
|
39
|
+
disabled_path = skill.path
|
|
40
|
+
enabled_path = disabled_path.sub(/#{Regexp.escape(DISABLED_SUFFIX)}$/o, "")
|
|
41
|
+
return false unless File.exist?(disabled_path)
|
|
42
|
+
|
|
43
|
+
File.rename(disabled_path, enabled_path)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def disable!(skill)
|
|
48
|
+
return false unless skill.enabled
|
|
49
|
+
enabled_path = skill.path
|
|
50
|
+
disabled_path = "#{enabled_path}#{DISABLED_SUFFIX}"
|
|
51
|
+
return false unless File.exist?(enabled_path)
|
|
52
|
+
|
|
53
|
+
File.rename(enabled_path, disabled_path)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def scan_scope(scope)
|
|
60
|
+
base = (scope == :global) ? global_base : local_base
|
|
61
|
+
skills = []
|
|
62
|
+
|
|
63
|
+
AGENTS.each do |agent|
|
|
64
|
+
skill_dir = File.join(base, agent[:skill_dir])
|
|
65
|
+
next unless Dir.exist?(skill_dir)
|
|
66
|
+
|
|
67
|
+
Dir.children(skill_dir).sort.each do |entry|
|
|
68
|
+
full_dir = File.join(skill_dir, entry)
|
|
69
|
+
next unless File.directory?(full_dir)
|
|
70
|
+
|
|
71
|
+
enabled_file = File.join(full_dir, SKILL_FILE)
|
|
72
|
+
disabled_file = File.join(full_dir, "#{SKILL_FILE}#{DISABLED_SUFFIX}")
|
|
73
|
+
|
|
74
|
+
if File.exist?(enabled_file)
|
|
75
|
+
skills << Skill.new(
|
|
76
|
+
name: entry,
|
|
77
|
+
scope: scope,
|
|
78
|
+
enabled: true,
|
|
79
|
+
path: enabled_file,
|
|
80
|
+
source: nil,
|
|
81
|
+
agent: agent[:name]
|
|
82
|
+
)
|
|
83
|
+
elsif File.exist?(disabled_file)
|
|
84
|
+
skills << Skill.new(
|
|
85
|
+
name: entry,
|
|
86
|
+
scope: scope,
|
|
87
|
+
enabled: false,
|
|
88
|
+
path: disabled_file,
|
|
89
|
+
source: nil,
|
|
90
|
+
agent: agent[:name]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
skills
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/shai/version.rb
CHANGED
data/lib/shai.rb
CHANGED
|
@@ -4,6 +4,9 @@ require_relative "shai/version"
|
|
|
4
4
|
require_relative "shai/configuration"
|
|
5
5
|
require_relative "shai/credentials"
|
|
6
6
|
require_relative "shai/api_client"
|
|
7
|
+
require_relative "shai/installed_projects"
|
|
8
|
+
require_relative "shai/install_registry"
|
|
9
|
+
require_relative "shai/skill_scanner"
|
|
7
10
|
require_relative "shai/cli"
|
|
8
11
|
|
|
9
12
|
module Shai
|
|
@@ -19,6 +22,41 @@ module Shai
|
|
|
19
22
|
|
|
20
23
|
class InvalidConfigurationError < Error; end
|
|
21
24
|
|
|
25
|
+
class RateLimitError < Error
|
|
26
|
+
attr_reader :retry_after
|
|
27
|
+
|
|
28
|
+
def initialize(message = "Too many requests", retry_after: nil)
|
|
29
|
+
@retry_after = retry_after
|
|
30
|
+
super(message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class DeviceFlowError < Error
|
|
35
|
+
attr_reader :error_code, :interval
|
|
36
|
+
|
|
37
|
+
def initialize(error_code, interval: nil)
|
|
38
|
+
@error_code = error_code
|
|
39
|
+
@interval = interval
|
|
40
|
+
super(error_code)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authorization_pending?
|
|
44
|
+
error_code == "authorization_pending"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def slow_down?
|
|
48
|
+
error_code == "slow_down"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def access_denied?
|
|
52
|
+
error_code == "access_denied"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def expired?
|
|
56
|
+
error_code == "expired_token"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
22
60
|
# Exit codes as specified in tech spec
|
|
23
61
|
EXIT_SUCCESS = 0
|
|
24
62
|
EXIT_GENERAL_ERROR = 1
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shai-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Jimenez
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - "~>"
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '3.4'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: launchy
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.5'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.5'
|
|
110
124
|
description: A command-line interface for shaicli.dev - download, share, and sync
|
|
111
125
|
AI agent configurations (Claude, Cursor, etc.) across projects and teams.
|
|
112
126
|
email:
|
|
@@ -125,9 +139,13 @@ files:
|
|
|
125
139
|
- lib/shai/commands/auth.rb
|
|
126
140
|
- lib/shai/commands/config.rb
|
|
127
141
|
- lib/shai/commands/configurations.rb
|
|
142
|
+
- lib/shai/commands/skills.rb
|
|
128
143
|
- lib/shai/commands/sync.rb
|
|
129
144
|
- lib/shai/configuration.rb
|
|
130
145
|
- lib/shai/credentials.rb
|
|
146
|
+
- lib/shai/install_registry.rb
|
|
147
|
+
- lib/shai/installed_projects.rb
|
|
148
|
+
- lib/shai/skill_scanner.rb
|
|
131
149
|
- lib/shai/ui.rb
|
|
132
150
|
- lib/shai/version.rb
|
|
133
151
|
homepage: https://shaicli.dev
|
|
@@ -152,7 +170,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
152
170
|
- !ruby/object:Gem::Version
|
|
153
171
|
version: '0'
|
|
154
172
|
requirements: []
|
|
155
|
-
rubygems_version:
|
|
173
|
+
rubygems_version: 4.0.3
|
|
156
174
|
specification_version: 4
|
|
157
175
|
summary: CLI tool for managing shared AI agent configurations
|
|
158
176
|
test_files: []
|