openclacky 0.6.2 ā 0.6.3
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/lib/clacky/agent.rb +542 -54
- data/lib/clacky/cli.rb +341 -2
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +34 -43
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- metadata +5 -1
data/lib/clacky/cli.rb
CHANGED
|
@@ -251,6 +251,338 @@ module Clacky
|
|
|
251
251
|
say "https://www.anthropic.com/pricing\n\n", :blue
|
|
252
252
|
end
|
|
253
253
|
|
|
254
|
+
desc "skills", "Manage and list skills"
|
|
255
|
+
long_desc <<-LONGDESC
|
|
256
|
+
Manage and list skills that extend Claude's capabilities.
|
|
257
|
+
|
|
258
|
+
Skills are reusable prompts with YAML frontmatter that define
|
|
259
|
+
when and how Claude should use them. Skills can be invoked
|
|
260
|
+
directly with /skill-name or loaded automatically based on context.
|
|
261
|
+
|
|
262
|
+
Skill locations (in priority order):
|
|
263
|
+
- .clacky/skills/ (project, highest priority)
|
|
264
|
+
- ~/.clacky/skills/ (user global)
|
|
265
|
+
- .claude/skills/ (project, compatibility)
|
|
266
|
+
- ~/.claude/skills/ (user global, compatibility)
|
|
267
|
+
|
|
268
|
+
Subcommands:
|
|
269
|
+
list - List all available skills
|
|
270
|
+
show <name> - Show details of a specific skill
|
|
271
|
+
|
|
272
|
+
Examples:
|
|
273
|
+
$ clacky skills list
|
|
274
|
+
$ clacky skills show explain-code
|
|
275
|
+
LONGDESC
|
|
276
|
+
subcommand_option_names = []
|
|
277
|
+
|
|
278
|
+
# Main skills command - delegates to subcommands or shows help
|
|
279
|
+
def skills(*args)
|
|
280
|
+
if args.empty?
|
|
281
|
+
invoke :help, ["skills"]
|
|
282
|
+
else
|
|
283
|
+
subcommand = args.shift
|
|
284
|
+
case subcommand
|
|
285
|
+
when "list"
|
|
286
|
+
skills_list
|
|
287
|
+
when "show"
|
|
288
|
+
skills_show(args.first)
|
|
289
|
+
when "create"
|
|
290
|
+
# Parse options for create
|
|
291
|
+
name = args.first
|
|
292
|
+
opts = {}
|
|
293
|
+
i = 1
|
|
294
|
+
while i < args.length
|
|
295
|
+
if args[i] == "--description"
|
|
296
|
+
opts[:description] = args[i + 1]
|
|
297
|
+
i += 2
|
|
298
|
+
elsif args[i] == "--content"
|
|
299
|
+
opts[:content] = args[i + 1]
|
|
300
|
+
i += 2
|
|
301
|
+
elsif args[i] == "--project"
|
|
302
|
+
opts[:project] = true
|
|
303
|
+
i += 1
|
|
304
|
+
else
|
|
305
|
+
i += 1
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
skills_create_with_opts(name, opts)
|
|
309
|
+
when "delete"
|
|
310
|
+
skills_delete(args.first)
|
|
311
|
+
else
|
|
312
|
+
say "Unknown skill subcommand: #{subcommand}", :red
|
|
313
|
+
invoke :help, ["skills"]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
desc "skills list", "List all available skills"
|
|
319
|
+
long_desc <<-LONGDESC
|
|
320
|
+
List all available skills from all configured locations:
|
|
321
|
+
- Project skills (.clacky/skills/)
|
|
322
|
+
- Global skills (~/.clacky/skills/)
|
|
323
|
+
- Compatible skills (.claude/skills/, ~/.claude/skills/)
|
|
324
|
+
|
|
325
|
+
Each skill shows:
|
|
326
|
+
- Name and slash command
|
|
327
|
+
- Description
|
|
328
|
+
- Whether it can be auto-invoked by Claude
|
|
329
|
+
- Whether it supports user invocation
|
|
330
|
+
LONGDESC
|
|
331
|
+
def skills_list
|
|
332
|
+
loader = Clacky::SkillLoader.new(Dir.pwd)
|
|
333
|
+
all_skills = loader.load_all
|
|
334
|
+
|
|
335
|
+
if all_skills.empty?
|
|
336
|
+
say "\nš No skills found.\n", :yellow
|
|
337
|
+
say "\nCreate your first skill:", :cyan
|
|
338
|
+
say " ~/.clacky/skills/<skill-name>/SKILL.md", :white
|
|
339
|
+
say " or .clacky/skills/<skill-name>/SKILL.md\n", :white
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
say "\nš Available Skills (#{all_skills.size})\n\n", :green
|
|
344
|
+
|
|
345
|
+
all_skills.each do |skill|
|
|
346
|
+
# Build status indicators
|
|
347
|
+
indicators = []
|
|
348
|
+
indicators << "š¤" if skill.model_invocation_allowed?
|
|
349
|
+
indicators << "š¤" if skill.user_invocable?
|
|
350
|
+
indicators << "š" if skill.forked_context?
|
|
351
|
+
|
|
352
|
+
say " /#{skill.identifier}", :cyan
|
|
353
|
+
say " #{indicators.join(' ')}" unless indicators.empty?
|
|
354
|
+
say "\n"
|
|
355
|
+
|
|
356
|
+
# Show description (truncated if too long)
|
|
357
|
+
desc = skill.context_description
|
|
358
|
+
if desc.length > 60
|
|
359
|
+
desc = desc[0..57] + "..."
|
|
360
|
+
end
|
|
361
|
+
say " #{desc}\n", :white
|
|
362
|
+
|
|
363
|
+
# Show location with priority indicator
|
|
364
|
+
location = case loader.loaded_from[skill.identifier]
|
|
365
|
+
when :default
|
|
366
|
+
"built-in"
|
|
367
|
+
when :project_clacky
|
|
368
|
+
"project .clacky"
|
|
369
|
+
when :project_claude
|
|
370
|
+
"project .claude (compat)"
|
|
371
|
+
when :global_clacky
|
|
372
|
+
"global .clacky"
|
|
373
|
+
when :global_claude
|
|
374
|
+
"global .claude (compat)"
|
|
375
|
+
else
|
|
376
|
+
"unknown"
|
|
377
|
+
end
|
|
378
|
+
say " [#{location}]\n", :yellow
|
|
379
|
+
|
|
380
|
+
say "\n"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Show errors if any
|
|
384
|
+
if loader.errors.any?
|
|
385
|
+
say "\nā ļø Warnings:\n", :yellow
|
|
386
|
+
loader.errors.each do |error|
|
|
387
|
+
say " - #{error}\n", :red
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
desc "skills show NAME", "Show details of a specific skill"
|
|
393
|
+
long_desc <<-LONGDESC
|
|
394
|
+
Show the full content and metadata of a specific skill.
|
|
395
|
+
|
|
396
|
+
NAME is the skill name (without the leading /).
|
|
397
|
+
|
|
398
|
+
Examples:
|
|
399
|
+
$ clacky skills show explain-code
|
|
400
|
+
LONGDESC
|
|
401
|
+
def skills_show(name = nil)
|
|
402
|
+
unless name
|
|
403
|
+
say "Error: Skill name required.\n", :red
|
|
404
|
+
say "Usage: clacky skills show <name>\n", :yellow
|
|
405
|
+
exit 1
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
loader = Clacky::SkillLoader.new(Dir.pwd)
|
|
409
|
+
all_skills = loader.load_all
|
|
410
|
+
|
|
411
|
+
# Try to find the skill
|
|
412
|
+
skill = all_skills.find { |s| s.identifier == name }
|
|
413
|
+
|
|
414
|
+
unless skill
|
|
415
|
+
# Try prefix matching
|
|
416
|
+
matching = all_skills.select { |s| s.identifier.start_with?(name) }
|
|
417
|
+
if matching.size == 1
|
|
418
|
+
skill = matching.first
|
|
419
|
+
else
|
|
420
|
+
say "\nā Skill '#{name}' not found.\n", :red
|
|
421
|
+
say "\nAvailable skills:\n", :yellow
|
|
422
|
+
all_skills.each { |s| say " /#{s.identifier}\n", :cyan }
|
|
423
|
+
exit 1
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Display skill details
|
|
428
|
+
say "\nš Skill: /#{skill.identifier}\n\n", :green
|
|
429
|
+
|
|
430
|
+
say "Description:\n", :yellow
|
|
431
|
+
say " #{skill.context_description}\n\n", :white
|
|
432
|
+
|
|
433
|
+
say "Status:\n", :yellow
|
|
434
|
+
say " Auto-invokable: #{skill.model_invocation_allowed? ? 'Yes' : 'No'}\n", :white
|
|
435
|
+
say " User-invokable: #{skill.user_invocable? ? 'Yes' : 'No'}\n", :white
|
|
436
|
+
say " Forked context: #{skill.forked_context? ? 'Yes' : 'No'}\n", :white
|
|
437
|
+
|
|
438
|
+
if skill.allowed_tools
|
|
439
|
+
say " Allowed tools: #{skill.allowed_tools.join(', ')}\n", :white
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
say "\nLocation: #{skill.source_path}\n\n", :yellow
|
|
443
|
+
|
|
444
|
+
say "Content:\n", :yellow
|
|
445
|
+
say "-" * 60 + "\n", :white
|
|
446
|
+
say skill.content, :white
|
|
447
|
+
say "\n" + "-" * 60 + "\n", :white
|
|
448
|
+
|
|
449
|
+
# Show supporting files if any
|
|
450
|
+
if skill.has_supporting_files?
|
|
451
|
+
say "\nSupporting files:\n", :yellow
|
|
452
|
+
skill.supporting_files.each do |file|
|
|
453
|
+
say " - #{file.relative_path_from(Pathname.new(Dir.pwd))}\n", :cyan
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
desc "skills create NAME", "Create a new skill"
|
|
459
|
+
long_desc <<-LONGDESC
|
|
460
|
+
Create a new skill in the global skills directory.
|
|
461
|
+
|
|
462
|
+
NAME is the skill name (lowercase letters, numbers, and hyphens only).
|
|
463
|
+
|
|
464
|
+
This creates a new directory at ~/.clacky/skills/NAME/SKILL.md
|
|
465
|
+
with a template skill file.
|
|
466
|
+
|
|
467
|
+
Options:
|
|
468
|
+
--description Set the skill description
|
|
469
|
+
--content Set the skill content (use - for stdin)
|
|
470
|
+
--project Create in project .clacky/skills/ instead
|
|
471
|
+
|
|
472
|
+
Examples:
|
|
473
|
+
$ clacky skills create explain-code --description "Explain code with diagrams"
|
|
474
|
+
$ clacky skills create deploy --description "Deploy application" --project
|
|
475
|
+
LONGDESC
|
|
476
|
+
option :description, type: :string, desc: "Skill description"
|
|
477
|
+
option :content, type: :string, desc: "Skill content (use - for stdin)"
|
|
478
|
+
option :project, type: :boolean, desc: "Create in project directory"
|
|
479
|
+
def skills_create(name = nil)
|
|
480
|
+
unless name
|
|
481
|
+
say "Error: Skill name required.\n", :red
|
|
482
|
+
say "Usage: clacky skills create <name>\n", :yellow
|
|
483
|
+
exit 1
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Validate name
|
|
487
|
+
unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
|
|
488
|
+
say "Error: Invalid skill name '#{name}'.\n", :red
|
|
489
|
+
say "Use lowercase letters, numbers, and hyphens only.\n", :yellow
|
|
490
|
+
exit 1
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Get description
|
|
494
|
+
description = options[:description] || ask("Skill description: ").to_s
|
|
495
|
+
|
|
496
|
+
# Get content
|
|
497
|
+
if options[:content] == "-"
|
|
498
|
+
say "Enter skill content (end with Ctrl+D):\n", :yellow
|
|
499
|
+
content = STDIN.read
|
|
500
|
+
elsif options[:content]
|
|
501
|
+
content = options[:content]
|
|
502
|
+
else
|
|
503
|
+
content = "Describe the skill here..."
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Determine location
|
|
507
|
+
location = options[:project] ? :project : :global
|
|
508
|
+
|
|
509
|
+
# Create the skill
|
|
510
|
+
loader = Clacky::SkillLoader.new(Dir.pwd)
|
|
511
|
+
skill = loader.create_skill(name, content, description, location: location)
|
|
512
|
+
|
|
513
|
+
skill_path = skill.directory
|
|
514
|
+
say "\nā
Skill created at: #{skill_path}\n", :green
|
|
515
|
+
say "\nYou can invoke it with: /#{name}\n", :cyan
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Helper method for skills command dispatcher
|
|
519
|
+
no_commands do
|
|
520
|
+
def skills_create_with_opts(name, opts = {})
|
|
521
|
+
unless name
|
|
522
|
+
say "Error: Skill name required.\n", :red
|
|
523
|
+
say "Usage: clacky skills create <name>\n", :yellow
|
|
524
|
+
exit 1
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Validate name
|
|
528
|
+
unless name.match?(/^[a-z0-9][a-z0-9-]*$/)
|
|
529
|
+
say "Error: Invalid skill name '#{name}'.\n", :red
|
|
530
|
+
say "Use lowercase letters, numbers, and hyphens only.\n", :yellow
|
|
531
|
+
exit 1
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
description = opts[:description] || ask("Skill description: ").to_s
|
|
535
|
+
content = opts[:content] || "Describe the skill here..."
|
|
536
|
+
location = opts[:project] ? :project : :global
|
|
537
|
+
|
|
538
|
+
loader = Clacky::SkillLoader.new(Dir.pwd)
|
|
539
|
+
skill = loader.create_skill(name, content, description, location: location)
|
|
540
|
+
|
|
541
|
+
skill_path = skill.directory
|
|
542
|
+
say "\nā
Skill created at: #{skill_path}\n", :green
|
|
543
|
+
say "\nYou can invoke it with: /#{name}\n", :cyan
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
desc "skills delete NAME", "Delete a skill"
|
|
548
|
+
long_desc <<-LONGDESC
|
|
549
|
+
Delete a skill by name.
|
|
550
|
+
|
|
551
|
+
NAME is the skill name (without the leading /).
|
|
552
|
+
|
|
553
|
+
Examples:
|
|
554
|
+
$ clacky skills delete explain-code
|
|
555
|
+
LONGDESC
|
|
556
|
+
def skills_delete(name = nil)
|
|
557
|
+
unless name
|
|
558
|
+
say "Error: Skill name required.\n", :red
|
|
559
|
+
say "Usage: clacky skills delete <name>\n", :yellow
|
|
560
|
+
exit 1
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
loader = Clacky::SkillLoader.new(Dir.pwd)
|
|
564
|
+
all_skills = loader.load_all
|
|
565
|
+
|
|
566
|
+
# Find the skill
|
|
567
|
+
skill = all_skills.find { |s| s.identifier == name }
|
|
568
|
+
|
|
569
|
+
unless skill
|
|
570
|
+
say "Error: Skill '#{name}' not found.\n", :red
|
|
571
|
+
exit 1
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Confirm deletion
|
|
575
|
+
prompt = TTY::Prompt.new
|
|
576
|
+
unless prompt.yes?("Delete skill '/#{name}' at #{skill.directory}?")
|
|
577
|
+
say "Cancelled.\n", :yellow
|
|
578
|
+
exit 0
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Delete the skill
|
|
582
|
+
loader.delete_skill(name)
|
|
583
|
+
say "\nā
Skill '/#{name}' deleted.\n", :green
|
|
584
|
+
end
|
|
585
|
+
|
|
254
586
|
no_commands do
|
|
255
587
|
def build_agent_config(config)
|
|
256
588
|
AgentConfig.new(
|
|
@@ -319,7 +651,8 @@ module Clacky
|
|
|
319
651
|
end
|
|
320
652
|
|
|
321
653
|
def load_session_by_number(client, agent_config, session_manager, working_dir, identifier)
|
|
322
|
-
|
|
654
|
+
# Get a larger list to search through (for ID prefix matching)
|
|
655
|
+
sessions = session_manager.list(current_dir: working_dir, limit: 100)
|
|
323
656
|
|
|
324
657
|
if sessions.empty?
|
|
325
658
|
say "No sessions found.", :yellow
|
|
@@ -329,7 +662,8 @@ module Clacky
|
|
|
329
662
|
session_data = nil
|
|
330
663
|
|
|
331
664
|
# Check if identifier is a number (index-based)
|
|
332
|
-
|
|
665
|
+
# Heuristic: If it's a small number (1-99), treat as index; otherwise treat as session ID prefix
|
|
666
|
+
if identifier.match?(/^\d+$/) && identifier.to_i <= 99
|
|
333
667
|
index = identifier.to_i - 1
|
|
334
668
|
if index < 0 || index >= sessions.size
|
|
335
669
|
say "Invalid session number. Use -l to list available sessions.", :red
|
|
@@ -399,6 +733,9 @@ module Clacky
|
|
|
399
733
|
# Inject UI into agent
|
|
400
734
|
agent.instance_variable_set(:@ui, ui_controller)
|
|
401
735
|
|
|
736
|
+
# Set skill loader for command suggestions
|
|
737
|
+
ui_controller.set_skill_loader(agent.skill_loader)
|
|
738
|
+
|
|
402
739
|
# Track agent thread state
|
|
403
740
|
agent_thread = nil
|
|
404
741
|
|
|
@@ -497,6 +834,8 @@ module Clacky
|
|
|
497
834
|
if is_session_load
|
|
498
835
|
recent_user_messages = agent.get_recent_user_messages(limit: 5)
|
|
499
836
|
ui_controller.initialize_and_show_banner(recent_user_messages: recent_user_messages)
|
|
837
|
+
# Update session bar with restored agent stats
|
|
838
|
+
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
500
839
|
else
|
|
501
840
|
ui_controller.initialize_and_show_banner
|
|
502
841
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-add
|
|
3
|
+
description: Guide for creating new SKILL.md files
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
user-invocable: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Skill Creation Guide
|
|
9
|
+
|
|
10
|
+
## SKILL.md Structure
|
|
11
|
+
|
|
12
|
+
### 1. Front Matter (Required)
|
|
13
|
+
```yaml
|
|
14
|
+
---
|
|
15
|
+
name: skill-name
|
|
16
|
+
description: Brief one-line description
|
|
17
|
+
disable-model-invocation: false
|
|
18
|
+
user-invocable: true
|
|
19
|
+
---
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 2. Main Content
|
|
23
|
+
```markdown
|
|
24
|
+
# Skill Title
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
How to invoke: "command description" or `/skill-name`
|
|
28
|
+
|
|
29
|
+
## Process Steps
|
|
30
|
+
|
|
31
|
+
### 1. First Step
|
|
32
|
+
What to do
|
|
33
|
+
|
|
34
|
+
### 2. Next Step
|
|
35
|
+
Continue the task
|
|
36
|
+
|
|
37
|
+
## Commands Used
|
|
38
|
+
```bash
|
|
39
|
+
# Key commands
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
- Important points
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## File Location
|
|
47
|
+
`.clacky/skills/{skill-name}/SKILL.md`
|
|
48
|
+
|
|
49
|
+
## Minimal Example
|
|
50
|
+
```markdown
|
|
51
|
+
---
|
|
52
|
+
name: hello
|
|
53
|
+
description: Simple greeting
|
|
54
|
+
disable-model-invocation: false
|
|
55
|
+
user-invocable: true
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# Hello Skill
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
Say "hello" or `/hello`
|
|
62
|
+
|
|
63
|
+
## Process Steps
|
|
64
|
+
### 1. Greet user
|
|
65
|
+
### 2. Offer help
|
|
66
|
+
```
|
data/lib/clacky/skill.rb
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# Represents a skill with its metadata and content.
|
|
8
|
+
# A skill is defined by a SKILL.md file with optional YAML frontmatter.
|
|
9
|
+
class Skill
|
|
10
|
+
# Frontmatter fields that are recognized
|
|
11
|
+
FRONTMATTER_FIELDS = %w[
|
|
12
|
+
name
|
|
13
|
+
description
|
|
14
|
+
disable-model-invocation
|
|
15
|
+
user-invocable
|
|
16
|
+
allowed-tools
|
|
17
|
+
context
|
|
18
|
+
agent
|
|
19
|
+
argument-hint
|
|
20
|
+
hooks
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :directory, :frontmatter, :source_path
|
|
24
|
+
attr_reader :name, :description, :content
|
|
25
|
+
attr_reader :disable_model_invocation, :user_invocable
|
|
26
|
+
attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
|
|
27
|
+
|
|
28
|
+
# @param directory [Pathname, String] Path to the skill directory
|
|
29
|
+
# @param source_path [Pathname, String, nil] Optional source path for priority resolution
|
|
30
|
+
def initialize(directory, source_path: nil)
|
|
31
|
+
@directory = Pathname.new(directory)
|
|
32
|
+
@source_path = source_path ? Pathname.new(source_path) : @directory
|
|
33
|
+
|
|
34
|
+
load_skill
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get the skill identifier (uses name from frontmatter or directory name)
|
|
38
|
+
# @return [String]
|
|
39
|
+
def identifier
|
|
40
|
+
@name || @directory.basename.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if skill can be invoked by user via slash command
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def user_invocable?
|
|
46
|
+
@user_invocable.nil? || @user_invocable
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if skill can be automatically invoked by the model
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def model_invocation_allowed?
|
|
52
|
+
!@disable_model_invocation
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if skill runs in a forked subagent context
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def forked_context?
|
|
58
|
+
@context == "fork"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get the slash command for this skill
|
|
62
|
+
# @return [String] e.g., "/explain-code"
|
|
63
|
+
def slash_command
|
|
64
|
+
"/#{identifier}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get the description for context loading
|
|
68
|
+
# Returns the description from frontmatter, or first paragraph of content
|
|
69
|
+
# @return [String]
|
|
70
|
+
def context_description
|
|
71
|
+
@description || extract_first_paragraph
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get all supporting files in the skill directory (excluding SKILL.md)
|
|
75
|
+
# @return [Array<Pathname>]
|
|
76
|
+
def supporting_files
|
|
77
|
+
return [] unless @directory.exist?
|
|
78
|
+
|
|
79
|
+
@directory.children.reject { |p| p.basename.to_s == "SKILL.md" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if this skill has supporting files
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def has_supporting_files?
|
|
85
|
+
supporting_files.any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Process the skill content with argument substitution
|
|
89
|
+
# @param arguments [String] Arguments passed to the skill
|
|
90
|
+
# @param shell_output [Hash] Shell command outputs for !command` syntax (optional)
|
|
91
|
+
# @return [String] Processed content
|
|
92
|
+
def process_content(arguments = "", shell_output: {})
|
|
93
|
+
processed_content = @content.dup
|
|
94
|
+
|
|
95
|
+
# Replace argument placeholders
|
|
96
|
+
processed_content = substitute_arguments(processed_content, arguments)
|
|
97
|
+
|
|
98
|
+
# Replace shell command outputs
|
|
99
|
+
shell_output.each do |command, output|
|
|
100
|
+
placeholder = "!`#{command}`"
|
|
101
|
+
processed_content.gsub!(placeholder, output.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
processed_content
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert to a hash representation
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def to_h
|
|
110
|
+
{
|
|
111
|
+
name: identifier,
|
|
112
|
+
description: context_description,
|
|
113
|
+
directory: @directory.to_s,
|
|
114
|
+
source_path: @source_path.to_s,
|
|
115
|
+
user_invocable: user_invocable?,
|
|
116
|
+
model_invocation_allowed: model_invocation_allowed?,
|
|
117
|
+
forked_context: forked_context?,
|
|
118
|
+
allowed_tools: @allowed_tools,
|
|
119
|
+
argument_hint: @argument_hint,
|
|
120
|
+
content_length: @content.length
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Load content of a supporting file
|
|
125
|
+
# @param filename [String] Relative path from skill directory
|
|
126
|
+
# @return [String, nil] File contents or nil if not found
|
|
127
|
+
def read_supporting_file(filename)
|
|
128
|
+
file_path = @directory.join(filename)
|
|
129
|
+
file_path.exist? ? file_path.read : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def load_skill
|
|
135
|
+
skill_file = @directory.join("SKILL.md")
|
|
136
|
+
|
|
137
|
+
unless skill_file.exist?
|
|
138
|
+
raise Clacky::Error, "SKILL.md not found in skill directory: #{@directory}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
content = skill_file.read
|
|
142
|
+
|
|
143
|
+
# Parse frontmatter if present
|
|
144
|
+
if content.start_with?("---")
|
|
145
|
+
parse_frontmatter(content)
|
|
146
|
+
else
|
|
147
|
+
@frontmatter = {}
|
|
148
|
+
@content = content
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Set defaults
|
|
152
|
+
@user_invocable = true if @user_invocable.nil?
|
|
153
|
+
@disable_model_invocation = false if @disable_model_invocation.nil?
|
|
154
|
+
|
|
155
|
+
validate_frontmatter
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_frontmatter(content)
|
|
159
|
+
# Extract frontmatter between first and second "---"
|
|
160
|
+
frontmatter_match = content.match(/^---\n(.*?)\n---/m)
|
|
161
|
+
|
|
162
|
+
unless frontmatter_match
|
|
163
|
+
raise Clacky::Error, "Invalid frontmatter format in SKILL.md: missing closing ---"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
yaml_content = frontmatter_match[1]
|
|
167
|
+
@frontmatter = YAML.safe_load(yaml_content) || {}
|
|
168
|
+
|
|
169
|
+
# Extract content after frontmatter
|
|
170
|
+
@content = content[frontmatter_match.end(0)..-1].to_s.strip
|
|
171
|
+
|
|
172
|
+
# Extract fields from frontmatter
|
|
173
|
+
@name = @frontmatter["name"]
|
|
174
|
+
@description = @frontmatter["description"]
|
|
175
|
+
@disable_model_invocation = @frontmatter["disable-model-invocation"]
|
|
176
|
+
@user_invocable = @frontmatter["user-invocable"]
|
|
177
|
+
@allowed_tools = @frontmatter["allowed-tools"]
|
|
178
|
+
@context = @frontmatter["context"]
|
|
179
|
+
@agent_type = @frontmatter["agent"]
|
|
180
|
+
@argument_hint = @frontmatter["argument-hint"]
|
|
181
|
+
@hooks = @frontmatter["hooks"]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_frontmatter
|
|
185
|
+
# Validate name if provided
|
|
186
|
+
if @name
|
|
187
|
+
unless @name.match?(/^[a-z0-9][a-z0-9-]*$/)
|
|
188
|
+
raise Clacky::Error,
|
|
189
|
+
"Invalid skill name '#{@name}'. Use lowercase letters, numbers, and hyphens only (max 64 chars)."
|
|
190
|
+
end
|
|
191
|
+
if @name.length > 64
|
|
192
|
+
raise Clacky::Error, "Skill name '#{@name}' exceeds 64 characters."
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Validate context
|
|
197
|
+
if @context && @context != "fork"
|
|
198
|
+
raise Clacky::Error, "Invalid context '#{@context}'. Only 'fork' is supported."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Validate allowed-tools format
|
|
202
|
+
if @allowed_tools && !@allowed_tools.is_a?(Array)
|
|
203
|
+
raise Clacky::Error, "allowed-tools must be an array of tool names"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_first_paragraph
|
|
208
|
+
@content.split(/\n\n/).first.to_s
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def substitute_arguments(content, arguments)
|
|
212
|
+
# Parse arguments as shell words for indexed access
|
|
213
|
+
args_array = arguments.shellsplit
|
|
214
|
+
|
|
215
|
+
# Replace $ARGUMENTS with all arguments
|
|
216
|
+
result = content.gsub("$ARGUMENTS", arguments.to_s)
|
|
217
|
+
|
|
218
|
+
# Replace $ARGUMENTS[N] with specific argument
|
|
219
|
+
result.gsub!(/\$ARGUMENTS\[(\d+)\]/) do
|
|
220
|
+
index = $1.to_i
|
|
221
|
+
args_array[index] || ""
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Replace $N shorthand ($0, $1, etc.)
|
|
225
|
+
result.gsub!(/\$([0-9]+)/) do
|
|
226
|
+
index = $1.to_i
|
|
227
|
+
args_array[index] || ""
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Replace ${CLAUDE_SESSION_ID} with empty string (session not available in current context)
|
|
231
|
+
result.gsub!(/\${CLAUDE_SESSION_ID}/, "")
|
|
232
|
+
|
|
233
|
+
result
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|