shai-cli 0.2.0 → 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 +67 -10
- data/lib/shai/cli.rb +9 -2
- data/lib/shai/commands/configurations.rb +77 -7
- data/lib/shai/commands/skills.rb +199 -0
- data/lib/shai/install_registry.rb +62 -0
- data/lib/shai/skill_scanner.rb +99 -0
- data/lib/shai/version.rb +1 -1
- data/lib/shai.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '00490a68be250015b1cd43dd40cc804d79e82bb74ddc6fc65a4ef90d27b7de69'
|
|
4
|
+
data.tar.gz: d694b3f181f571b3ac8ebbbe415c92c805c4fb780cd8f3dae4cce0f6b32e6a6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08980b4e9a5623c01215cdbe58b1ad8d2ade01cd07ca4d84a3c61db346cbf0513efd663037740570fd17c8c2e61e14b7597d1444bd6701afad6a595ab2f95763'
|
|
7
|
+
data.tar.gz: 94016712713d1f3731b82b39a75e0d4d23b37f4149a2503bcfe8ae09aabf1ad59f03be7107f1dad0b1177e2168981fc07a5ab3ceaff123822adf1192220be7f0
|
data/README.md
CHANGED
|
@@ -56,8 +56,8 @@ shai login
|
|
|
56
56
|
# Search for public configurations
|
|
57
57
|
shai search "claude code"
|
|
58
58
|
|
|
59
|
-
# Install a configuration
|
|
60
|
-
shai install anthropic/claude-expert
|
|
59
|
+
# Install a configuration (--global for ~/, --local for ./)
|
|
60
|
+
shai install anthropic/claude-expert --local
|
|
61
61
|
|
|
62
62
|
# Create and share your own configuration
|
|
63
63
|
shai init
|
|
@@ -76,12 +76,12 @@ These commands work without authentication for public configurations:
|
|
|
76
76
|
| ------------------------- | ------------------------------------------------------ |
|
|
77
77
|
| `shai search <query>` | Search public configurations |
|
|
78
78
|
| `shai install <config>` | Install a public configuration (use `owner/slug` format) |
|
|
79
|
-
| `shai uninstall <config>` | Uninstall a
|
|
79
|
+
| `shai uninstall <config>` | Uninstall a configuration |
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
82
|
# No login needed for public configs
|
|
83
83
|
shai search "claude code"
|
|
84
|
-
shai install anthropic/claude-expert
|
|
84
|
+
shai install anthropic/claude-expert --local
|
|
85
85
|
shai uninstall anthropic/claude-expert
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -151,21 +151,69 @@ Token expires: March 11, 2026
|
|
|
151
151
|
|
|
152
152
|
### Using Configurations
|
|
153
153
|
|
|
154
|
-
| Command
|
|
155
|
-
|
|
|
156
|
-
| `shai install <config>`
|
|
157
|
-
| `shai
|
|
154
|
+
| Command | Description |
|
|
155
|
+
| -------------------------------------- | ---------------------------------------- |
|
|
156
|
+
| `shai install <config>` | Install a configuration (prompts for location) |
|
|
157
|
+
| `shai install <config> --global` | Install to home directory (`~/`) |
|
|
158
|
+
| `shai install <config> --local` | Install to current directory (`./`) |
|
|
159
|
+
| `shai install <config> --path <dir>` | Install to a specific directory |
|
|
160
|
+
| `shai uninstall <config>` | Remove an installed configuration |
|
|
161
|
+
| `shai uninstall <config> --global` | Uninstall from home directory |
|
|
162
|
+
| `shai uninstall <config> --local` | Uninstall from current directory |
|
|
163
|
+
|
|
164
|
+
**Global vs Local installs:**
|
|
165
|
+
|
|
166
|
+
Configurations can be installed globally (to `~/`) or locally (to `./`). Global installs apply across all projects — useful for AI agent config files like `CLAUDE.md` or `.cursorrules`. Local installs are project-specific.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Install globally (applies everywhere)
|
|
170
|
+
$ shai install anthropic/claude-expert --global
|
|
171
|
+
|
|
172
|
+
# Install locally (current project only)
|
|
173
|
+
$ shai install anthropic/claude-expert --local
|
|
174
|
+
|
|
175
|
+
# If you don't specify, shai will ask
|
|
176
|
+
$ shai install anthropic/claude-expert
|
|
177
|
+
? Where do you want to install?
|
|
178
|
+
./ (local - current directory)
|
|
179
|
+
~/ (global - home directory)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Uninstalling:**
|
|
183
|
+
|
|
184
|
+
shai tracks where each configuration was installed, so uninstall works without needing to remember the path:
|
|
158
185
|
|
|
159
186
|
```bash
|
|
160
187
|
$ shai uninstall anthropic/claude-expert
|
|
161
|
-
|
|
162
|
-
Remove 3 files and 1 folder from 'anthropic/claude-expert'? (y/N) y
|
|
188
|
+
Remove 3 files from 'anthropic/claude-expert'? (y/N) y
|
|
163
189
|
|
|
164
190
|
✓ Uninstalled anthropic/claude-expert
|
|
165
191
|
```
|
|
166
192
|
|
|
167
193
|
---
|
|
168
194
|
|
|
195
|
+
### Skills
|
|
196
|
+
|
|
197
|
+
| Command | Description |
|
|
198
|
+
| ------------------------------------------------ | ---------------------------------------- |
|
|
199
|
+
| `shai skills` | List all AI agent skills and their status |
|
|
200
|
+
| `shai skills enable <name>` | Enable a disabled skill |
|
|
201
|
+
| `shai skills enable <name> --global` | Enable a global skill |
|
|
202
|
+
| `shai skills disable <name>` | Disable an enabled skill |
|
|
203
|
+
| `shai skills disable <name> --local` | Disable a local skill |
|
|
204
|
+
| `shai skills disable <name> --agent codex` | Target a specific agent |
|
|
205
|
+
|
|
206
|
+
Skills are `SKILL.md` files discovered across multiple AI agent directories:
|
|
207
|
+
|
|
208
|
+
| Agent | Global path | Local path |
|
|
209
|
+
| ------ | ------------------------------ | ----------------------------- |
|
|
210
|
+
| Claude | `~/.claude/skills/*/SKILL.md` | `./.claude/skills/*/SKILL.md` |
|
|
211
|
+
| Codex | `~/.agents/skills/*/SKILL.md` | `./.agents/skills/*/SKILL.md` |
|
|
212
|
+
|
|
213
|
+
Disabling a skill renames `SKILL.md` to `SKILL.md.disabled` so the AI tool no longer loads it. Re-enabling reverses the rename. Use `--agent` to target a specific agent when the same skill name exists in multiple agents.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
169
217
|
### Authoring Configurations
|
|
170
218
|
|
|
171
219
|
| Command | Description |
|
|
@@ -218,6 +266,12 @@ exclude:
|
|
|
218
266
|
## Examples
|
|
219
267
|
|
|
220
268
|
```bash
|
|
269
|
+
# Install globally (to ~/)
|
|
270
|
+
shai install anthropic/claude-expert --global
|
|
271
|
+
|
|
272
|
+
# Install locally (to ./)
|
|
273
|
+
shai install anthropic/claude-expert --local
|
|
274
|
+
|
|
221
275
|
# Install to a specific directory
|
|
222
276
|
shai install anthropic/claude-expert --path ./my-project
|
|
223
277
|
|
|
@@ -226,6 +280,9 @@ shai install anthropic/claude-expert --dry-run
|
|
|
226
280
|
|
|
227
281
|
# Force overwrite existing files
|
|
228
282
|
shai install anthropic/claude-expert --force
|
|
283
|
+
|
|
284
|
+
# Uninstall (auto-detects where it was installed)
|
|
285
|
+
shai uninstall anthropic/claude-expert
|
|
229
286
|
```
|
|
230
287
|
|
|
231
288
|
---
|
data/lib/shai/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "commands/auth"
|
|
|
5
5
|
require_relative "commands/configurations"
|
|
6
6
|
require_relative "commands/sync"
|
|
7
7
|
require_relative "commands/config"
|
|
8
|
+
require_relative "commands/skills"
|
|
8
9
|
require_relative "ui"
|
|
9
10
|
|
|
10
11
|
module Shai
|
|
@@ -13,6 +14,7 @@ module Shai
|
|
|
13
14
|
include Commands::Configurations
|
|
14
15
|
include Commands::Sync
|
|
15
16
|
include Commands::Config
|
|
17
|
+
include Commands::Skills
|
|
16
18
|
|
|
17
19
|
def self.exit_on_failure?
|
|
18
20
|
true
|
|
@@ -36,10 +38,15 @@ module Shai
|
|
|
36
38
|
shell.say " search <query> Search public configurations"
|
|
37
39
|
shell.say " open <config> Open a configuration in the browser"
|
|
38
40
|
shell.say ""
|
|
39
|
-
shell.say "USING CONFIGURATIONS
|
|
40
|
-
shell.say " install <config> Install a configuration
|
|
41
|
+
shell.say "USING CONFIGURATIONS:"
|
|
42
|
+
shell.say " install <config> Install a configuration (--global, --local, or --path)"
|
|
41
43
|
shell.say " uninstall <config> Remove an installed configuration"
|
|
42
44
|
shell.say ""
|
|
45
|
+
shell.say "SKILLS:"
|
|
46
|
+
shell.say " skills List all AI agent skills and their status"
|
|
47
|
+
shell.say " skills enable <n> Enable a disabled skill (--global, --local, --agent)"
|
|
48
|
+
shell.say " skills disable <n> Disable an enabled skill (--global, --local, --agent)"
|
|
49
|
+
shell.say ""
|
|
43
50
|
shell.say "AUTHORING CONFIGURATIONS (create and publish):"
|
|
44
51
|
shell.say " init Initialize a new configuration"
|
|
45
52
|
shell.say " push Push local changes to remote"
|
|
@@ -71,16 +71,30 @@ module Shai
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
desc "install CONFIGURATION", "Install a configuration
|
|
74
|
+
desc "install CONFIGURATION", "Install a configuration"
|
|
75
75
|
option :force, type: :boolean, aliases: "-f", default: false, desc: "Overwrite existing files"
|
|
76
76
|
option :dry_run, type: :boolean, default: false, desc: "Show what would be installed"
|
|
77
|
-
option :path, type: :string,
|
|
77
|
+
option :path, type: :string, desc: "Install to specific directory"
|
|
78
|
+
option :global, type: :boolean, default: false, desc: "Install to home directory (~)"
|
|
79
|
+
option :local, type: :boolean, default: false, desc: "Install to current directory (./)"
|
|
78
80
|
def install(configuration)
|
|
79
81
|
owner, slug = parse_configuration_name(configuration)
|
|
80
82
|
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
81
|
-
base_path =
|
|
83
|
+
base_path = resolve_install_path
|
|
82
84
|
shairc_path = File.join(base_path, ".shairc")
|
|
83
85
|
|
|
86
|
+
registry = InstallRegistry.new
|
|
87
|
+
|
|
88
|
+
# Check if already installed elsewhere
|
|
89
|
+
if registry.has?(display_name) && !options[:force]
|
|
90
|
+
existing_path = registry.path_for(display_name)
|
|
91
|
+
if File.expand_path(existing_path) != base_path
|
|
92
|
+
ui.error("'#{display_name}' is already installed at #{existing_path}")
|
|
93
|
+
ui.info("Run `shai uninstall #{display_name}` first, or use --force to reinstall.")
|
|
94
|
+
exit EXIT_INVALID_INPUT
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
84
98
|
installed = InstalledProjects.new(base_path)
|
|
85
99
|
|
|
86
100
|
# Check if this exact project is already installed
|
|
@@ -214,6 +228,7 @@ module Shai
|
|
|
214
228
|
|
|
215
229
|
# Track the installed project
|
|
216
230
|
installed.add_project(display_name, created_files)
|
|
231
|
+
registry.add(display_name, base_path)
|
|
217
232
|
|
|
218
233
|
# Record the install for analytics (fire and forget)
|
|
219
234
|
begin
|
|
@@ -301,17 +316,21 @@ module Shai
|
|
|
301
316
|
end
|
|
302
317
|
end
|
|
303
318
|
|
|
304
|
-
desc "uninstall [CONFIGURATION]", "Remove an installed configuration
|
|
319
|
+
desc "uninstall [CONFIGURATION]", "Remove an installed configuration"
|
|
305
320
|
option :dry_run, type: :boolean, default: false, desc: "Show what would be removed"
|
|
306
|
-
option :
|
|
321
|
+
option :global, type: :boolean, default: false, desc: "Uninstall from home directory (~)"
|
|
322
|
+
option :local, type: :boolean, default: false, desc: "Uninstall from current directory (./)"
|
|
307
323
|
def uninstall(configuration = nil)
|
|
308
|
-
|
|
324
|
+
registry = InstallRegistry.new
|
|
325
|
+
|
|
326
|
+
# Resolve base_path: explicit flag, registry lookup, or fallback to "."
|
|
327
|
+
base_path = resolve_uninstall_path(configuration, registry)
|
|
309
328
|
installed = InstalledProjects.new(base_path)
|
|
310
329
|
|
|
311
330
|
# If no configuration specified, determine which to uninstall
|
|
312
331
|
if configuration.nil?
|
|
313
332
|
if installed.empty?
|
|
314
|
-
ui.error("No configurations installed in
|
|
333
|
+
ui.error("No configurations installed in #{base_path}")
|
|
315
334
|
ui.info("Usage: shai uninstall <configuration>")
|
|
316
335
|
exit EXIT_INVALID_INPUT
|
|
317
336
|
elsif installed.project_count == 1
|
|
@@ -420,6 +439,7 @@ module Shai
|
|
|
420
439
|
|
|
421
440
|
# Update tracking
|
|
422
441
|
installed.remove_project(display_name)
|
|
442
|
+
registry.remove(display_name)
|
|
423
443
|
|
|
424
444
|
# Remove tracking file if no more projects
|
|
425
445
|
if installed.empty?
|
|
@@ -439,6 +459,56 @@ module Shai
|
|
|
439
459
|
|
|
440
460
|
private
|
|
441
461
|
|
|
462
|
+
def resolve_uninstall_path(configuration, registry)
|
|
463
|
+
if options[:global] && options[:local]
|
|
464
|
+
ui.error("Conflicting options: use only one of --global or --local.")
|
|
465
|
+
exit EXIT_INVALID_INPUT
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
if options[:global]
|
|
469
|
+
return File.expand_path(Dir.home)
|
|
470
|
+
elsif options[:local]
|
|
471
|
+
return File.expand_path(".")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Try registry lookup if a configuration name is given
|
|
475
|
+
if configuration
|
|
476
|
+
owner, slug = parse_configuration_name(configuration)
|
|
477
|
+
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
478
|
+
registered_path = registry.path_for(display_name)
|
|
479
|
+
return File.expand_path(registered_path) if registered_path
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Default to current directory
|
|
483
|
+
File.expand_path(".")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def resolve_install_path
|
|
487
|
+
flag_count = 0
|
|
488
|
+
flag_count += 1 if options[:global]
|
|
489
|
+
flag_count += 1 if options[:local]
|
|
490
|
+
flag_count += 1 if options[:path]
|
|
491
|
+
|
|
492
|
+
if flag_count > 1
|
|
493
|
+
ui.error("Conflicting options: use only one of --global, --local, or --path.")
|
|
494
|
+
exit EXIT_INVALID_INPUT
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if options[:path]
|
|
498
|
+
File.expand_path(options[:path])
|
|
499
|
+
elsif options[:global]
|
|
500
|
+
File.expand_path(Dir.home)
|
|
501
|
+
elsif options[:local]
|
|
502
|
+
File.expand_path(".")
|
|
503
|
+
else
|
|
504
|
+
choice = ui.select("Where do you want to install?", [
|
|
505
|
+
{name: "./ (local - current directory)", value: :local},
|
|
506
|
+
{name: "~/ (global - home directory)", value: :global}
|
|
507
|
+
])
|
|
508
|
+
(choice == :global) ? File.expand_path(Dir.home) : File.expand_path(".")
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
442
512
|
def parse_configuration_name(name)
|
|
443
513
|
if name.include?("/")
|
|
444
514
|
name.split("/", 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,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
|
@@ -5,6 +5,8 @@ require_relative "shai/configuration"
|
|
|
5
5
|
require_relative "shai/credentials"
|
|
6
6
|
require_relative "shai/api_client"
|
|
7
7
|
require_relative "shai/installed_projects"
|
|
8
|
+
require_relative "shai/install_registry"
|
|
9
|
+
require_relative "shai/skill_scanner"
|
|
8
10
|
require_relative "shai/cli"
|
|
9
11
|
|
|
10
12
|
module Shai
|
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
|
|
@@ -139,10 +139,13 @@ files:
|
|
|
139
139
|
- lib/shai/commands/auth.rb
|
|
140
140
|
- lib/shai/commands/config.rb
|
|
141
141
|
- lib/shai/commands/configurations.rb
|
|
142
|
+
- lib/shai/commands/skills.rb
|
|
142
143
|
- lib/shai/commands/sync.rb
|
|
143
144
|
- lib/shai/configuration.rb
|
|
144
145
|
- lib/shai/credentials.rb
|
|
146
|
+
- lib/shai/install_registry.rb
|
|
145
147
|
- lib/shai/installed_projects.rb
|
|
148
|
+
- lib/shai/skill_scanner.rb
|
|
146
149
|
- lib/shai/ui.rb
|
|
147
150
|
- lib/shai/version.rb
|
|
148
151
|
homepage: https://shaicli.dev
|
|
@@ -167,7 +170,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
167
170
|
- !ruby/object:Gem::Version
|
|
168
171
|
version: '0'
|
|
169
172
|
requirements: []
|
|
170
|
-
rubygems_version:
|
|
173
|
+
rubygems_version: 4.0.3
|
|
171
174
|
specification_version: 4
|
|
172
175
|
summary: CLI tool for managing shared AI agent configurations
|
|
173
176
|
test_files: []
|