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.
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
- sessions = session_manager.list(current_dir: working_dir, limit: 10)
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
- if identifier.match?(/^\d+$/)
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
+ ```
@@ -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