harnex 0.2.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 +7 -0
- data/GUIDE.md +242 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/TECHNICAL.md +595 -0
- data/bin/harnex +18 -0
- data/lib/harnex/adapters/base.rb +134 -0
- data/lib/harnex/adapters/claude.rb +105 -0
- data/lib/harnex/adapters/codex.rb +112 -0
- data/lib/harnex/adapters/generic.rb +14 -0
- data/lib/harnex/adapters.rb +32 -0
- data/lib/harnex/cli.rb +115 -0
- data/lib/harnex/commands/guide.rb +23 -0
- data/lib/harnex/commands/logs.rb +184 -0
- data/lib/harnex/commands/pane.rb +251 -0
- data/lib/harnex/commands/recipes.rb +104 -0
- data/lib/harnex/commands/run.rb +384 -0
- data/lib/harnex/commands/send.rb +415 -0
- data/lib/harnex/commands/skills.rb +163 -0
- data/lib/harnex/commands/status.rb +171 -0
- data/lib/harnex/commands/stop.rb +127 -0
- data/lib/harnex/commands/wait.rb +165 -0
- data/lib/harnex/core.rb +286 -0
- data/lib/harnex/runtime/api_server.rb +187 -0
- data/lib/harnex/runtime/file_change_hook.rb +111 -0
- data/lib/harnex/runtime/inbox.rb +207 -0
- data/lib/harnex/runtime/message.rb +23 -0
- data/lib/harnex/runtime/session.rb +380 -0
- data/lib/harnex/runtime/session_state.rb +55 -0
- data/lib/harnex/version.rb +3 -0
- data/lib/harnex/watcher/inotify.rb +43 -0
- data/lib/harnex/watcher/polling.rb +92 -0
- data/lib/harnex/watcher.rb +24 -0
- data/lib/harnex.rb +25 -0
- data/recipes/01_fire_and_watch.md +82 -0
- data/recipes/02_chain_implement.md +115 -0
- data/skills/chain-implement/SKILL.md +234 -0
- data/skills/close/SKILL.md +47 -0
- data/skills/dispatch/SKILL.md +171 -0
- data/skills/harnex/SKILL.md +304 -0
- data/skills/open/SKILL.md +32 -0
- metadata +88 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
class Sender
|
|
8
|
+
DEFAULT_TIMEOUT = 120.0
|
|
9
|
+
MIN_HTTP_TIMEOUT = 0.1
|
|
10
|
+
POLL_INTERVAL = 0.5
|
|
11
|
+
IDLE_FENCE_TIMEOUT = 30.0
|
|
12
|
+
class TimeoutError < RuntimeError; end
|
|
13
|
+
|
|
14
|
+
def self.build_parser(options, program_name = "harnex send")
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.banner = "Usage: #{program_name} --id ID [options] [text...]"
|
|
17
|
+
opts.on("--id ID", "Target session ID") { |value| options[:id] = Harnex.normalize_id(value) }
|
|
18
|
+
opts.on("--repo PATH", "Resolve the target using PATH's repo root") { |value| options[:repo_path] = value }
|
|
19
|
+
opts.on("--cli CLI", "Filter by CLI type") { |value| options[:cli] = value }
|
|
20
|
+
opts.on("--message TEXT", "Message text to inject instead of positional args") { |value| options[:message] = value }
|
|
21
|
+
opts.on("--no-submit", "Inject text without pressing Enter") { options[:submit] = false }
|
|
22
|
+
opts.on("--submit-only", "Press Enter without injecting text") do
|
|
23
|
+
options[:submit_only] = true
|
|
24
|
+
options[:submit] = true
|
|
25
|
+
end
|
|
26
|
+
opts.on("--force", "Send even if the agent is not at a prompt") { options[:force] = true }
|
|
27
|
+
opts.on("--no-wait", "Return immediately after queueing (HTTP 202). Use for fire-and-forget or when polling delivery separately.") { options[:wait] = false }
|
|
28
|
+
opts.on("--wait-for-idle", "After a successful send, wait for the agent to return to prompt") { options[:wait_for_idle] = true }
|
|
29
|
+
opts.on("--relay", "Force relay header formatting") { options[:relay] = true }
|
|
30
|
+
opts.on("--no-relay", "Disable automatic relay headers") { options[:relay] = false }
|
|
31
|
+
opts.on("--port PORT", Integer, "Send directly to a specific port") { |value| options[:port] = value }
|
|
32
|
+
opts.on("--token TOKEN", "Auth token for --port mode") { |value| options[:token] = value }
|
|
33
|
+
opts.on("--host HOST", "Override the host when --port is used") { |value| options[:host] = value }
|
|
34
|
+
opts.on("--timeout SECS", Float, "How long to wait for lookup, delivery, or idle wait (default: #{DEFAULT_TIMEOUT.to_i})") { |value| options[:timeout] = value }
|
|
35
|
+
opts.on("--verbose", "Print lookup and delivery details to stderr") { options[:verbose] = true }
|
|
36
|
+
opts.on("-h", "--help", "Show help") { options[:help] = true }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.usage(program_name = "harnex send")
|
|
41
|
+
build_parser({}, program_name).to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(argv)
|
|
45
|
+
@options = {
|
|
46
|
+
repo_path: Dir.pwd,
|
|
47
|
+
id: nil,
|
|
48
|
+
cli: nil,
|
|
49
|
+
message: nil,
|
|
50
|
+
submit: true,
|
|
51
|
+
submit_only: false,
|
|
52
|
+
relay: nil,
|
|
53
|
+
force: false,
|
|
54
|
+
wait: true,
|
|
55
|
+
wait_for_idle: false,
|
|
56
|
+
port: nil,
|
|
57
|
+
token: nil,
|
|
58
|
+
host: DEFAULT_HOST,
|
|
59
|
+
timeout: DEFAULT_TIMEOUT,
|
|
60
|
+
verbose: false,
|
|
61
|
+
help: false
|
|
62
|
+
}
|
|
63
|
+
@argv = argv.dup
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run
|
|
67
|
+
parser.parse!(@argv)
|
|
68
|
+
if @options[:help]
|
|
69
|
+
puts parser
|
|
70
|
+
return 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
raise "--id is required for harnex send" unless @options[:id]
|
|
74
|
+
validate_modes!
|
|
75
|
+
|
|
76
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
77
|
+
@command_started_at = monotonic_now
|
|
78
|
+
deadline = @command_started_at + @options[:timeout]
|
|
79
|
+
verbose("repo_root=#{repo_root}")
|
|
80
|
+
verbose("id=#{@options[:id]}")
|
|
81
|
+
verbose("cli=#{@options[:cli] || '(any)'}")
|
|
82
|
+
|
|
83
|
+
registry = wait_for_registry(repo_root, deadline: deadline)
|
|
84
|
+
case registry
|
|
85
|
+
when :ambiguous
|
|
86
|
+
raise ambiguous_session_message(repo_root)
|
|
87
|
+
when :timeout
|
|
88
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", error: "lookup timed out after #{@options[:timeout]}s")
|
|
89
|
+
return 124
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
registry = direct_registry.merge(registry || {})
|
|
93
|
+
verbose("target=http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
|
|
94
|
+
|
|
95
|
+
text = resolve_text
|
|
96
|
+
text = relay_text(text, registry)
|
|
97
|
+
raise "text is required" if text.to_s.empty? && !@options[:submit_only]
|
|
98
|
+
|
|
99
|
+
response = with_http_retry(deadline: deadline) do
|
|
100
|
+
uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
|
|
101
|
+
request = Net::HTTP::Post.new(uri)
|
|
102
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
103
|
+
request["Content-Type"] = "application/json"
|
|
104
|
+
request.body = JSON.generate(
|
|
105
|
+
text: text,
|
|
106
|
+
submit: @options[:submit],
|
|
107
|
+
enter_only: @options[:submit_only],
|
|
108
|
+
force: @options[:force]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
Net::HTTP.start(
|
|
112
|
+
uri.host,
|
|
113
|
+
uri.port,
|
|
114
|
+
open_timeout: http_timeout(deadline),
|
|
115
|
+
read_timeout: http_timeout(deadline)
|
|
116
|
+
) { |http| http.request(request) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
result = nil
|
|
120
|
+
exit_code = nil
|
|
121
|
+
|
|
122
|
+
if response.code == "202" && @options[:wait]
|
|
123
|
+
parsed = parse_json_body(response.body)
|
|
124
|
+
message_id = parsed["message_id"]
|
|
125
|
+
if message_id
|
|
126
|
+
verbose("queued message_id=#{message_id}")
|
|
127
|
+
result = poll_delivery(registry, message_id, deadline: deadline)
|
|
128
|
+
exit_code =
|
|
129
|
+
case result["status"]
|
|
130
|
+
when "timeout" then 124
|
|
131
|
+
when "delivered" then 0
|
|
132
|
+
else 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
unless result
|
|
138
|
+
result = parse_json_body(response.body)
|
|
139
|
+
exit_code = response.is_a?(Net::HTTPSuccess) ? 0 : 1
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if exit_code == 0 && @options[:wait_for_idle]
|
|
143
|
+
result = wait_for_idle_state(registry, deadline)
|
|
144
|
+
exit_code = result["ok"] ? 0 : (result["state"] == "exited" ? 1 : 124)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
puts JSON.generate(result)
|
|
148
|
+
exit_code
|
|
149
|
+
rescue TimeoutError => e
|
|
150
|
+
puts JSON.generate(ok: false, id: @options[:id], status: "timeout", error: e.message)
|
|
151
|
+
124
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def resolve_text
|
|
157
|
+
return "" if @options[:submit_only]
|
|
158
|
+
return @options[:message] if @options[:message]
|
|
159
|
+
return @argv.join(" ") unless @argv.empty?
|
|
160
|
+
return STDIN.read unless STDIN.tty?
|
|
161
|
+
|
|
162
|
+
raise "harnex send: no message provided (use --message, positional args, or pipe)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def poll_delivery(registry, message_id, deadline:)
|
|
166
|
+
base = "http://#{registry.fetch('host')}:#{registry.fetch('port')}"
|
|
167
|
+
uri = URI("#{base}/messages/#{message_id}")
|
|
168
|
+
|
|
169
|
+
loop do
|
|
170
|
+
return({ "ok" => false, "status" => "timeout", "error" => timeout_message("delivery") }) if deadline_reached?(deadline)
|
|
171
|
+
|
|
172
|
+
request = Net::HTTP::Get.new(uri)
|
|
173
|
+
request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
|
|
174
|
+
response = Net::HTTP.start(
|
|
175
|
+
uri.host,
|
|
176
|
+
uri.port,
|
|
177
|
+
open_timeout: http_timeout(deadline, cap: 1.0),
|
|
178
|
+
read_timeout: http_timeout(deadline, cap: 2.0)
|
|
179
|
+
) { |http| http.request(request) }
|
|
180
|
+
parsed = parse_json_body(response.body)
|
|
181
|
+
status = parsed["status"]
|
|
182
|
+
|
|
183
|
+
return parsed if %w[delivered failed].include?(status)
|
|
184
|
+
return parsed.merge("status" => "timeout", "error" => timeout_message("delivery")) if deadline_reached?(deadline)
|
|
185
|
+
|
|
186
|
+
verbose("delivery status=#{status || 'unknown'}")
|
|
187
|
+
sleep 0.25
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
return({ "ok" => false, "status" => "timeout", "error" => timeout_message("delivery") }) if deadline_reached?(deadline)
|
|
190
|
+
|
|
191
|
+
sleep 0.25
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def wait_for_idle_state(registry, deadline)
|
|
196
|
+
host = registry.fetch("host")
|
|
197
|
+
port = registry.fetch("port")
|
|
198
|
+
token = registry["token"]
|
|
199
|
+
last_state = "prompt"
|
|
200
|
+
fence_deadline = [monotonic_now + IDLE_FENCE_TIMEOUT, deadline].min
|
|
201
|
+
|
|
202
|
+
loop do
|
|
203
|
+
return wait_for_idle_timeout(last_state) if deadline_reached?(deadline)
|
|
204
|
+
|
|
205
|
+
state = fetch_agent_state(host, port, token)
|
|
206
|
+
return wait_for_idle_exited if state.nil?
|
|
207
|
+
|
|
208
|
+
last_state = state
|
|
209
|
+
break if state != "prompt"
|
|
210
|
+
return wait_for_idle_success("prompt") if monotonic_now >= fence_deadline
|
|
211
|
+
|
|
212
|
+
sleep POLL_INTERVAL
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
loop do
|
|
216
|
+
return wait_for_idle_timeout(last_state) if deadline_reached?(deadline)
|
|
217
|
+
|
|
218
|
+
state = fetch_agent_state(host, port, token)
|
|
219
|
+
return wait_for_idle_exited if state.nil?
|
|
220
|
+
|
|
221
|
+
last_state = state
|
|
222
|
+
return wait_for_idle_success(state) if state == "prompt"
|
|
223
|
+
|
|
224
|
+
sleep POLL_INTERVAL
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def relay_text(text, registry)
|
|
229
|
+
return text if text.to_s.empty?
|
|
230
|
+
return text unless relay_enabled_for?(registry)
|
|
231
|
+
return text if text.lstrip.start_with?("[harnex relay ")
|
|
232
|
+
|
|
233
|
+
context = Harnex.current_session_context
|
|
234
|
+
Harnex.format_relay_message(
|
|
235
|
+
text,
|
|
236
|
+
from: context.fetch(:cli),
|
|
237
|
+
id: context.fetch(:id)
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def relay_enabled_for?(registry)
|
|
242
|
+
return false if @options[:submit_only]
|
|
243
|
+
return false if @options[:relay] == false
|
|
244
|
+
|
|
245
|
+
context = Harnex.current_session_context
|
|
246
|
+
return false unless context
|
|
247
|
+
return true if @options[:relay] == true
|
|
248
|
+
|
|
249
|
+
target_session_id = registry["session_id"].to_s
|
|
250
|
+
return false if target_session_id.empty?
|
|
251
|
+
|
|
252
|
+
target_session_id != context.fetch(:session_id)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def verbose(message)
|
|
256
|
+
return unless @options[:verbose]
|
|
257
|
+
|
|
258
|
+
warn("[harnex send] #{message}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def wait_for_registry(repo_root, deadline:)
|
|
262
|
+
return nil if @options[:port]
|
|
263
|
+
|
|
264
|
+
loop do
|
|
265
|
+
registry = resolve_registry(repo_root)
|
|
266
|
+
return registry unless registry == :retry
|
|
267
|
+
return :timeout if deadline_reached?(deadline)
|
|
268
|
+
|
|
269
|
+
sleep 0.1
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolve_registry(repo_root)
|
|
274
|
+
sessions = Harnex.active_sessions(repo_root, id: @options[:id], cli: @options[:cli])
|
|
275
|
+
return :retry if sessions.empty?
|
|
276
|
+
return sessions.first if sessions.length == 1
|
|
277
|
+
|
|
278
|
+
:ambiguous
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def direct_registry
|
|
282
|
+
return {} unless @options[:port]
|
|
283
|
+
|
|
284
|
+
registry = {
|
|
285
|
+
"host" => @options[:host],
|
|
286
|
+
"port" => @options[:port]
|
|
287
|
+
}
|
|
288
|
+
registry["token"] = @options[:token] if @options[:token]
|
|
289
|
+
registry
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def missing_session_message(repo_root)
|
|
293
|
+
base = "no active harnex session found for #{repo_root}"
|
|
294
|
+
filters = []
|
|
295
|
+
filters << "id: #{@options[:id]}" if @options[:id]
|
|
296
|
+
filters << "cli: #{@options[:cli]}" if @options[:cli]
|
|
297
|
+
return "#{base} (#{filters.join(', ')})" unless filters.empty?
|
|
298
|
+
|
|
299
|
+
available = Harnex.active_sessions(repo_root).map { |session| "#{session['id']}(#{Harnex.session_cli(session)})" }.uniq.sort
|
|
300
|
+
suffix = available.empty? ? "" : " | active: #{available.join(', ')}"
|
|
301
|
+
"#{base}#{suffix}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def ambiguous_session_message(repo_root)
|
|
305
|
+
available = Harnex.active_sessions(repo_root, id: @options[:id], cli: @options[:cli])
|
|
306
|
+
detail = available.map { |session| "#{session['id']}(#{Harnex.session_cli(session)})" }.join(", ")
|
|
307
|
+
filters = []
|
|
308
|
+
filters << "id #{@options[:id].inspect}" if @options[:id]
|
|
309
|
+
filters << "cli #{@options[:cli].inspect}" if @options[:cli]
|
|
310
|
+
scope = filters.empty? ? repo_root : "#{repo_root} with #{filters.join(' and ')}"
|
|
311
|
+
"multiple active harnex sessions found for #{scope}; use --id, --cli, or `harnex status` | active: #{detail}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def with_http_retry(deadline:)
|
|
315
|
+
loop do
|
|
316
|
+
raise TimeoutError, timeout_message("request") if deadline_reached?(deadline)
|
|
317
|
+
|
|
318
|
+
return yield
|
|
319
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, Net::ReadTimeout, Net::OpenTimeout => e
|
|
320
|
+
raise TimeoutError, timeout_message("request") if deadline_reached?(deadline)
|
|
321
|
+
|
|
322
|
+
verbose("retrying after #{e.class}")
|
|
323
|
+
sleep 0.1
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def fetch_agent_state(host, port, token)
|
|
328
|
+
uri = URI("http://#{host}:#{port}/status")
|
|
329
|
+
request = Net::HTTP::Get.new(uri)
|
|
330
|
+
request["Authorization"] = "Bearer #{token}" if token
|
|
331
|
+
|
|
332
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
333
|
+
http.request(request)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
337
|
+
|
|
338
|
+
data = JSON.parse(response.body)
|
|
339
|
+
data["agent_state"]
|
|
340
|
+
rescue StandardError
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def monotonic_now
|
|
345
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def deadline_reached?(deadline)
|
|
349
|
+
monotonic_now >= deadline
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def http_timeout(deadline, cap: nil)
|
|
353
|
+
remaining = deadline - monotonic_now
|
|
354
|
+
remaining = [remaining, MIN_HTTP_TIMEOUT].max
|
|
355
|
+
cap ? [remaining, cap].min : remaining
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def timeout_message(phase)
|
|
359
|
+
"#{phase} timed out after #{@options[:timeout]}s"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def waited_seconds
|
|
363
|
+
return 0.0 unless @command_started_at
|
|
364
|
+
|
|
365
|
+
(monotonic_now - @command_started_at).round(1)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def wait_for_idle_success(state)
|
|
369
|
+
{
|
|
370
|
+
"ok" => true,
|
|
371
|
+
"id" => @options[:id],
|
|
372
|
+
"state" => state,
|
|
373
|
+
"waited_seconds" => waited_seconds
|
|
374
|
+
}
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def wait_for_idle_timeout(state)
|
|
378
|
+
{
|
|
379
|
+
"ok" => false,
|
|
380
|
+
"id" => @options[:id],
|
|
381
|
+
"state" => state || "unknown",
|
|
382
|
+
"error" => "timeout",
|
|
383
|
+
"waited_seconds" => waited_seconds
|
|
384
|
+
}
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def wait_for_idle_exited
|
|
388
|
+
{
|
|
389
|
+
"ok" => false,
|
|
390
|
+
"id" => @options[:id],
|
|
391
|
+
"state" => "exited",
|
|
392
|
+
"waited_seconds" => waited_seconds
|
|
393
|
+
}
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def parse_json_body(body)
|
|
397
|
+
JSON.parse(body.to_s.empty? ? "{}" : body.to_s)
|
|
398
|
+
rescue JSON::ParserError
|
|
399
|
+
{ "ok" => false, "error" => "invalid json response", "raw_body" => body.to_s }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def validate_modes!
|
|
403
|
+
raise "--submit-only cannot be combined with --no-submit" if @options[:submit_only] && !@options[:submit]
|
|
404
|
+
|
|
405
|
+
return unless @options[:submit_only]
|
|
406
|
+
return if @options[:message].nil? && @argv.empty?
|
|
407
|
+
|
|
408
|
+
raise "--submit-only does not accept message text"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def parser
|
|
412
|
+
@parser ||= self.class.build_parser(@options)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
class Skills
|
|
5
|
+
SKILLS_ROOT = File.expand_path("../../../../skills", __FILE__)
|
|
6
|
+
DEFAULT_SKILL = "harnex"
|
|
7
|
+
|
|
8
|
+
def self.usage
|
|
9
|
+
<<~TEXT
|
|
10
|
+
Usage: harnex skills install [SKILL...] [--global]
|
|
11
|
+
|
|
12
|
+
Subcommands:
|
|
13
|
+
install Install bundled skills into the current repo
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--global Install to ~/.claude/skills and ~/.codex/skills
|
|
17
|
+
instead of the current repo
|
|
18
|
+
|
|
19
|
+
Available skills: #{bundled_skill_names.join(', ')}
|
|
20
|
+
|
|
21
|
+
With no SKILL argument, installs #{DEFAULT_SKILL.inspect}.
|
|
22
|
+
Pass one or more skill names to install specific skills.
|
|
23
|
+
|
|
24
|
+
Without --global, copies the skill to .claude/skills/<skill>/
|
|
25
|
+
in the current repo and symlinks .codex/skills/<skill> to it.
|
|
26
|
+
|
|
27
|
+
With --global, symlinks both ~/.claude/skills/<skill> and
|
|
28
|
+
~/.codex/skills/<skill> to the bundled source.
|
|
29
|
+
TEXT
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.bundled_skill_names
|
|
33
|
+
Dir.children(SKILLS_ROOT).select { |name| File.directory?(File.join(SKILLS_ROOT, name)) }.sort
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(argv)
|
|
37
|
+
@argv = argv.dup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
subcommand = @argv.shift
|
|
42
|
+
case subcommand
|
|
43
|
+
when "install"
|
|
44
|
+
skill_names, global, help = parse_install_args(@argv)
|
|
45
|
+
if help
|
|
46
|
+
puts self.class.usage
|
|
47
|
+
return 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
skill_names.each do |skill_name|
|
|
51
|
+
skill_source = resolve_skill_source(skill_name)
|
|
52
|
+
unless skill_source
|
|
53
|
+
return missing_skill(skill_name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result = global ? install_global(skill_name, skill_source) : install_local(skill_name, skill_source)
|
|
57
|
+
return result unless result == 0
|
|
58
|
+
end
|
|
59
|
+
0
|
|
60
|
+
when "-h", "--help", nil
|
|
61
|
+
puts self.class.usage
|
|
62
|
+
0
|
|
63
|
+
else
|
|
64
|
+
warn("harnex skills: unknown subcommand #{subcommand.inspect}")
|
|
65
|
+
puts self.class.usage
|
|
66
|
+
1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_install_args(args)
|
|
73
|
+
skill_names = []
|
|
74
|
+
global = false
|
|
75
|
+
help = false
|
|
76
|
+
|
|
77
|
+
args.each do |arg|
|
|
78
|
+
case arg
|
|
79
|
+
when "--global"
|
|
80
|
+
global = true
|
|
81
|
+
when "-h", "--help"
|
|
82
|
+
help = true
|
|
83
|
+
when /\A-/
|
|
84
|
+
raise "harnex skills: unknown option #{arg.inspect}"
|
|
85
|
+
else
|
|
86
|
+
skill_names << arg
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
skill_names = [DEFAULT_SKILL] if skill_names.empty?
|
|
91
|
+
|
|
92
|
+
[skill_names, global, help]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_skill_source(skill_name)
|
|
96
|
+
return nil unless self.class.bundled_skill_names.include?(skill_name)
|
|
97
|
+
|
|
98
|
+
File.join(SKILLS_ROOT, skill_name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def missing_skill(skill_name)
|
|
102
|
+
warn("harnex skills: unknown skill #{skill_name.inspect}")
|
|
103
|
+
available = self.class.bundled_skill_names
|
|
104
|
+
warn("available skills: #{available.join(', ')}") unless available.empty?
|
|
105
|
+
1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def install_local(skill_name, skill_source)
|
|
109
|
+
repo_root = Harnex.resolve_repo_root(Dir.pwd)
|
|
110
|
+
claude_dir = File.join(repo_root, ".claude", "skills", skill_name)
|
|
111
|
+
codex_dir = File.join(repo_root, ".codex", "skills", skill_name)
|
|
112
|
+
|
|
113
|
+
# Copy skill to .claude/skills/<skill>/
|
|
114
|
+
if Dir.exist?(claude_dir)
|
|
115
|
+
warn("harnex skills: #{claude_dir} already exists, overwriting")
|
|
116
|
+
FileUtils.rm_rf(claude_dir)
|
|
117
|
+
end
|
|
118
|
+
FileUtils.mkdir_p(File.dirname(claude_dir))
|
|
119
|
+
FileUtils.cp_r(skill_source, claude_dir)
|
|
120
|
+
puts "installed #{claude_dir}"
|
|
121
|
+
|
|
122
|
+
# Symlink .codex/skills/<skill> -> .claude/skills/<skill>
|
|
123
|
+
codex_parent = File.dirname(codex_dir)
|
|
124
|
+
FileUtils.mkdir_p(codex_parent)
|
|
125
|
+
FileUtils.rm_rf(codex_dir) if File.exist?(codex_dir) || File.symlink?(codex_dir)
|
|
126
|
+
|
|
127
|
+
# Relative symlink so it works if the repo moves
|
|
128
|
+
relative = relative_path(from: codex_parent, to: claude_dir)
|
|
129
|
+
File.symlink(relative, codex_dir)
|
|
130
|
+
puts "symlinked #{codex_dir} -> #{relative}"
|
|
131
|
+
|
|
132
|
+
0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def install_global(skill_name, skill_source)
|
|
136
|
+
claude_dir = File.expand_path("~/.claude/skills/#{skill_name}")
|
|
137
|
+
codex_dir = File.expand_path("~/.codex/skills/#{skill_name}")
|
|
138
|
+
|
|
139
|
+
[claude_dir, codex_dir].each do |dir|
|
|
140
|
+
parent = File.dirname(dir)
|
|
141
|
+
FileUtils.mkdir_p(parent)
|
|
142
|
+
FileUtils.rm_rf(dir) if File.exist?(dir) || File.symlink?(dir)
|
|
143
|
+
File.symlink(skill_source, dir)
|
|
144
|
+
puts "symlinked #{dir} -> #{skill_source}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def relative_path(from:, to:)
|
|
151
|
+
from_parts = File.expand_path(from).split("/")
|
|
152
|
+
to_parts = File.expand_path(to).split("/")
|
|
153
|
+
|
|
154
|
+
# Drop common prefix
|
|
155
|
+
while from_parts.first == to_parts.first && !from_parts.empty?
|
|
156
|
+
from_parts.shift
|
|
157
|
+
to_parts.shift
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
([".."] * from_parts.length + to_parts).join("/")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|