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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shai
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.1
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: 3.6.9
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: []