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,184 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
class Logs
|
|
5
|
+
DEFAULT_LINES = 200
|
|
6
|
+
POLL_INTERVAL = 0.1
|
|
7
|
+
READ_CHUNK_SIZE = 4096
|
|
8
|
+
|
|
9
|
+
def self.usage(program_name = "harnex logs")
|
|
10
|
+
<<~TEXT
|
|
11
|
+
Usage: #{program_name} [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--id ID Session ID to inspect (required)
|
|
15
|
+
--repo PATH Resolve using PATH's repo root (default: current repo)
|
|
16
|
+
--cli CLI Filter the active session by CLI
|
|
17
|
+
--follow Keep streaming appended output until session exit
|
|
18
|
+
--lines N Print the last N lines before following (default: #{DEFAULT_LINES})
|
|
19
|
+
-h, --help Show this help
|
|
20
|
+
TEXT
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(argv)
|
|
24
|
+
@argv = argv.dup
|
|
25
|
+
@options = {
|
|
26
|
+
id: nil,
|
|
27
|
+
repo_path: Dir.pwd,
|
|
28
|
+
cli: nil,
|
|
29
|
+
follow: false,
|
|
30
|
+
lines: DEFAULT_LINES,
|
|
31
|
+
help: false
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
parser.parse!(@argv)
|
|
37
|
+
if @options[:help]
|
|
38
|
+
puts self.class.usage
|
|
39
|
+
return 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise "--id is required for harnex logs" unless @options[:id]
|
|
43
|
+
raise OptionParser::InvalidArgument, "--lines must be >= 0" if @options[:lines].negative?
|
|
44
|
+
|
|
45
|
+
target = resolve_target
|
|
46
|
+
return 1 unless target
|
|
47
|
+
|
|
48
|
+
offset = print_snapshot(target.fetch(:path))
|
|
49
|
+
return 0 unless @options[:follow] && target[:live]
|
|
50
|
+
|
|
51
|
+
follow(target.fetch(:path), offset, target.fetch(:pid))
|
|
52
|
+
0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parser
|
|
58
|
+
@parser ||= OptionParser.new do |opts|
|
|
59
|
+
opts.banner = "Usage: harnex logs [options]"
|
|
60
|
+
opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
|
|
61
|
+
opts.on("--repo PATH", "Resolve using PATH's repo root") { |value| @options[:repo_path] = value }
|
|
62
|
+
opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
|
|
63
|
+
opts.on("--follow", "Keep streaming appended output until session exit") { @options[:follow] = true }
|
|
64
|
+
opts.on("--lines N", Integer, "Print the last N lines before following") { |value| @options[:lines] = value }
|
|
65
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_target
|
|
70
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
71
|
+
session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
|
|
72
|
+
|
|
73
|
+
if session
|
|
74
|
+
path = session["output_log_path"].to_s
|
|
75
|
+
path = Harnex.output_log_path(repo_root, @options[:id]) if path.empty?
|
|
76
|
+
return log_target(path, live: true, pid: session["pid"], repo_root: repo_root)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if cli_filter_mismatch?(repo_root)
|
|
80
|
+
warn("harnex logs: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
log_target(Harnex.output_log_path(repo_root, @options[:id]), live: false, pid: nil, repo_root: repo_root)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cli_filter_mismatch?(repo_root)
|
|
88
|
+
return false unless @options[:cli]
|
|
89
|
+
|
|
90
|
+
session = Harnex.read_registry(repo_root, @options[:id])
|
|
91
|
+
return false unless session
|
|
92
|
+
|
|
93
|
+
Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def log_target(path, live:, pid:, repo_root:)
|
|
97
|
+
return { path: path, live: live, pid: pid, repo_root: repo_root } if File.file?(path)
|
|
98
|
+
|
|
99
|
+
if live
|
|
100
|
+
warn("harnex logs: transcript not found at #{path}")
|
|
101
|
+
else
|
|
102
|
+
warn("harnex logs: no session or transcript found with id #{@options[:id].inspect} for #{repo_root}")
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_snapshot(path)
|
|
108
|
+
data, offset = snapshot_data(path, @options[:lines])
|
|
109
|
+
$stdout.write(data)
|
|
110
|
+
$stdout.flush
|
|
111
|
+
offset
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def snapshot_data(path, line_limit)
|
|
115
|
+
size = File.size?(path).to_i
|
|
116
|
+
return ["".b, size] if size.zero? || line_limit.zero?
|
|
117
|
+
|
|
118
|
+
offset = size
|
|
119
|
+
buffer = +"".b
|
|
120
|
+
|
|
121
|
+
File.open(path, "rb") do |file|
|
|
122
|
+
while offset.positive?
|
|
123
|
+
chunk_size = [READ_CHUNK_SIZE, offset].min
|
|
124
|
+
offset -= chunk_size
|
|
125
|
+
file.seek(offset)
|
|
126
|
+
chunk = file.read(chunk_size)
|
|
127
|
+
next if chunk.nil? || chunk.empty?
|
|
128
|
+
|
|
129
|
+
buffer = chunk.b + buffer
|
|
130
|
+
break if buffer.count("\n") >= line_limit
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
lines = buffer.lines
|
|
135
|
+
data =
|
|
136
|
+
if lines.length > line_limit
|
|
137
|
+
lines.last(line_limit).join
|
|
138
|
+
else
|
|
139
|
+
buffer
|
|
140
|
+
end
|
|
141
|
+
[data, size]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def follow(path, offset, pid)
|
|
145
|
+
current_offset = offset
|
|
146
|
+
|
|
147
|
+
loop do
|
|
148
|
+
current_offset = stream_growth(path, current_offset)
|
|
149
|
+
unless Harnex.alive_pid?(pid)
|
|
150
|
+
drain_growth(path, current_offset)
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sleep POLL_INTERVAL
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def drain_growth(path, offset)
|
|
159
|
+
current_offset = offset
|
|
160
|
+
|
|
161
|
+
loop do
|
|
162
|
+
next_offset = stream_growth(path, current_offset)
|
|
163
|
+
return next_offset if next_offset == current_offset
|
|
164
|
+
|
|
165
|
+
current_offset = next_offset
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def stream_growth(path, offset)
|
|
170
|
+
size = File.size?(path).to_i
|
|
171
|
+
offset = size if offset > size
|
|
172
|
+
return offset if size == offset
|
|
173
|
+
|
|
174
|
+
File.open(path, "rb") do |file|
|
|
175
|
+
file.seek(offset)
|
|
176
|
+
while (chunk = file.read(READ_CHUNK_SIZE))
|
|
177
|
+
$stdout.write(chunk)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
$stdout.flush
|
|
181
|
+
size
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Harnex
|
|
7
|
+
class Pane
|
|
8
|
+
FOLLOW_INTERVAL = 1.0
|
|
9
|
+
|
|
10
|
+
def self.usage(program_name = "harnex pane")
|
|
11
|
+
<<~TEXT
|
|
12
|
+
Usage: #{program_name} [options]
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--id ID Session ID to inspect (required)
|
|
16
|
+
--repo PATH Resolve using PATH's repo root (default: current repo)
|
|
17
|
+
--cli CLI Filter the active session by CLI
|
|
18
|
+
--lines N Capture the last N lines instead of the full pane
|
|
19
|
+
--follow Refresh the pane snapshot every second until the session exits
|
|
20
|
+
--interval N Refresh interval in seconds for --follow (default: #{FOLLOW_INTERVAL.to_i})
|
|
21
|
+
--json Output JSON with capture metadata
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
TEXT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(argv)
|
|
27
|
+
@argv = argv.dup
|
|
28
|
+
@options = {
|
|
29
|
+
id: nil,
|
|
30
|
+
repo_path: Dir.pwd,
|
|
31
|
+
repo_explicit: false,
|
|
32
|
+
cli: nil,
|
|
33
|
+
lines: nil,
|
|
34
|
+
follow: false,
|
|
35
|
+
interval: FOLLOW_INTERVAL,
|
|
36
|
+
json: false,
|
|
37
|
+
help: false
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
parser.parse!(@argv)
|
|
43
|
+
if @options[:help]
|
|
44
|
+
puts self.class.usage
|
|
45
|
+
return 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise "--id is required for harnex pane" unless @options[:id]
|
|
49
|
+
if @options[:lines] && @options[:lines] < 1
|
|
50
|
+
raise OptionParser::InvalidArgument, "--lines must be >= 1"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
session = resolve_session
|
|
54
|
+
return 1 unless session
|
|
55
|
+
|
|
56
|
+
return 1 unless tmux_available?
|
|
57
|
+
|
|
58
|
+
target = resolve_tmux_target(session)
|
|
59
|
+
return 1 unless target
|
|
60
|
+
return 1 unless tmux_target_exists?(session, target)
|
|
61
|
+
|
|
62
|
+
if @options[:follow]
|
|
63
|
+
follow(session, target)
|
|
64
|
+
else
|
|
65
|
+
text = capture(target)
|
|
66
|
+
return 1 unless text
|
|
67
|
+
|
|
68
|
+
emit_output(session.fetch("id"), text)
|
|
69
|
+
end
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def parser
|
|
76
|
+
@parser ||= OptionParser.new do |opts|
|
|
77
|
+
opts.banner = "Usage: harnex pane [options]"
|
|
78
|
+
opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
|
|
79
|
+
opts.on("--repo PATH", "Resolve using PATH's repo root") do |value|
|
|
80
|
+
@options[:repo_path] = value
|
|
81
|
+
@options[:repo_explicit] = true
|
|
82
|
+
end
|
|
83
|
+
opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
|
|
84
|
+
opts.on("--lines N", Integer, "Capture the last N lines instead of the full pane") { |value| @options[:lines] = value }
|
|
85
|
+
opts.on("--follow", "Refresh the pane snapshot until the session exits") { @options[:follow] = true }
|
|
86
|
+
opts.on("--interval N", Float, "Refresh interval in seconds for --follow") { |value| @options[:interval] = value }
|
|
87
|
+
opts.on("--json", "Output JSON with capture metadata") { @options[:json] = true }
|
|
88
|
+
opts.on("-h", "--help", "Show help") { @options[:help] = true }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve_session
|
|
93
|
+
repo_root = Harnex.resolve_repo_root(@options[:repo_path])
|
|
94
|
+
session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
|
|
95
|
+
return session if session
|
|
96
|
+
|
|
97
|
+
unless @options[:repo_explicit]
|
|
98
|
+
candidates = Harnex.active_sessions(nil, id: @options[:id], cli: @options[:cli])
|
|
99
|
+
return candidates.first if candidates.length == 1
|
|
100
|
+
|
|
101
|
+
if candidates.length > 1
|
|
102
|
+
repos = candidates.map { |candidate| candidate["repo_root"].to_s }.uniq.sort
|
|
103
|
+
warn("harnex pane: multiple active sessions found with id #{@options[:id].inspect}; use --repo to disambiguate: #{repos.join(', ')}")
|
|
104
|
+
return nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if cli_filter_mismatch?(repo_root)
|
|
109
|
+
warn("harnex pane: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
|
|
110
|
+
else
|
|
111
|
+
warn("harnex pane: no active session found with id #{@options[:id].inspect} for #{repo_root}")
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def cli_filter_mismatch?(repo_root)
|
|
117
|
+
return false unless @options[:cli]
|
|
118
|
+
|
|
119
|
+
session = Harnex.read_registry(repo_root, @options[:id])
|
|
120
|
+
return false unless session
|
|
121
|
+
|
|
122
|
+
Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve_tmux_target(session)
|
|
126
|
+
target = session["tmux_target"].to_s.strip
|
|
127
|
+
return target unless target.empty?
|
|
128
|
+
|
|
129
|
+
discovery = Harnex.tmux_pane_for_pid(session["pid"])
|
|
130
|
+
if discovery
|
|
131
|
+
persist_tmux_metadata(session, discovery)
|
|
132
|
+
return discovery.fetch(:target)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
tmux_session = session["tmux_session"].to_s.strip
|
|
136
|
+
tmux_window = session["tmux_window"].to_s.strip
|
|
137
|
+
unless tmux_session.empty? || tmux_window.empty?
|
|
138
|
+
return "#{tmux_session}:#{tmux_window}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
warn("harnex pane: session #{session.fetch('id').inspect} is not tmux-backed or the tmux pane could not be located")
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def persist_tmux_metadata(session, discovery)
|
|
146
|
+
session["tmux_target"] = discovery.fetch(:target)
|
|
147
|
+
session["tmux_session"] = discovery.fetch(:session_name)
|
|
148
|
+
session["tmux_window"] = discovery.fetch(:window_name)
|
|
149
|
+
|
|
150
|
+
path = session["registry_path"].to_s
|
|
151
|
+
return if path.empty? || !File.exist?(path)
|
|
152
|
+
|
|
153
|
+
payload = JSON.parse(File.read(path))
|
|
154
|
+
payload["tmux_target"] = session["tmux_target"]
|
|
155
|
+
payload["tmux_session"] = session["tmux_session"]
|
|
156
|
+
payload["tmux_window"] = session["tmux_window"]
|
|
157
|
+
Harnex.write_registry(path, payload)
|
|
158
|
+
rescue JSON::ParserError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def tmux_available?
|
|
163
|
+
return true if system("tmux", "-V", out: File::NULL, err: File::NULL)
|
|
164
|
+
|
|
165
|
+
warn("harnex pane: tmux is not installed or not available in PATH")
|
|
166
|
+
false
|
|
167
|
+
rescue Errno::ENOENT
|
|
168
|
+
warn("harnex pane: tmux is not installed or not available in PATH")
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def tmux_target_exists?(session, target)
|
|
173
|
+
return true if system("tmux", "has-session", "-t", target, out: File::NULL, err: File::NULL)
|
|
174
|
+
|
|
175
|
+
warn("harnex pane: session #{session.fetch('id').inspect} is not tmux-backed or the tmux target #{target.inspect} no longer exists")
|
|
176
|
+
false
|
|
177
|
+
rescue Errno::ENOENT
|
|
178
|
+
warn("harnex pane: tmux is not installed or not available in PATH")
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def follow(session, target)
|
|
183
|
+
pid = session["pid"].to_i
|
|
184
|
+
last_text = nil
|
|
185
|
+
|
|
186
|
+
loop do
|
|
187
|
+
text = capture(target)
|
|
188
|
+
break unless text
|
|
189
|
+
|
|
190
|
+
if text != last_text
|
|
191
|
+
clear_screen
|
|
192
|
+
if @options[:json]
|
|
193
|
+
emit_output(session.fetch("id"), text)
|
|
194
|
+
else
|
|
195
|
+
$stdout.write(text)
|
|
196
|
+
$stdout.flush
|
|
197
|
+
end
|
|
198
|
+
last_text = text
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
break unless Harnex.alive_pid?(pid)
|
|
202
|
+
|
|
203
|
+
sleep @options[:interval]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def clear_screen
|
|
208
|
+
$stdout.write("\e[H\e[2J")
|
|
209
|
+
$stdout.flush
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def capture(target)
|
|
213
|
+
stdout, stderr, status = capture_command(capture_command_args(target))
|
|
214
|
+
return stdout if status.success?
|
|
215
|
+
|
|
216
|
+
message = stderr.to_s.strip
|
|
217
|
+
message = "tmux capture-pane failed" if message.empty?
|
|
218
|
+
warn("harnex pane: #{message}")
|
|
219
|
+
nil
|
|
220
|
+
rescue Errno::ENOENT
|
|
221
|
+
warn("harnex pane: tmux is not installed or not available in PATH")
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def capture_command_args(target)
|
|
226
|
+
command = ["tmux", "capture-pane", "-t", target, "-p"]
|
|
227
|
+
command += ["-S", "-#{@options[:lines]}"] if @options[:lines]
|
|
228
|
+
command
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def capture_command(command)
|
|
232
|
+
Open3.capture3(*command)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def emit_output(id, text)
|
|
236
|
+
if @options[:json]
|
|
237
|
+
puts JSON.generate({
|
|
238
|
+
ok: true,
|
|
239
|
+
id: id,
|
|
240
|
+
captured_at: Time.now.iso8601,
|
|
241
|
+
lines: @options[:lines],
|
|
242
|
+
text: text
|
|
243
|
+
})
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
$stdout.write(text)
|
|
248
|
+
$stdout.flush
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Harnex
|
|
4
|
+
class Recipes
|
|
5
|
+
RECIPES_DIR = File.expand_path("../../../../recipes", __FILE__)
|
|
6
|
+
|
|
7
|
+
def self.usage
|
|
8
|
+
<<~TEXT
|
|
9
|
+
Usage: harnex recipes [list|show <name>]
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
list List available recipes (default)
|
|
13
|
+
show <name> Print a recipe by name or number
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
harnex recipes
|
|
17
|
+
harnex recipes list
|
|
18
|
+
harnex recipes show 01
|
|
19
|
+
harnex recipes show fire_and_watch
|
|
20
|
+
TEXT
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(argv)
|
|
24
|
+
@argv = argv.dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
subcommand = @argv.shift
|
|
29
|
+
case subcommand
|
|
30
|
+
when nil, "list"
|
|
31
|
+
list_recipes
|
|
32
|
+
when "show"
|
|
33
|
+
show_recipe(@argv.first)
|
|
34
|
+
when "-h", "--help"
|
|
35
|
+
puts self.class.usage
|
|
36
|
+
0
|
|
37
|
+
else
|
|
38
|
+
# Treat bare arg as show
|
|
39
|
+
show_recipe(subcommand)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def list_recipes
|
|
46
|
+
files = recipe_files
|
|
47
|
+
if files.empty?
|
|
48
|
+
puts "No recipes found."
|
|
49
|
+
return 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
puts "Recipes:\n\n"
|
|
53
|
+
files.each do |file|
|
|
54
|
+
name = File.basename(file, ".md")
|
|
55
|
+
title = extract_title(file)
|
|
56
|
+
puts " #{name} #{title}"
|
|
57
|
+
end
|
|
58
|
+
puts "\nRun `harnex recipes show <name>` to read one."
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def show_recipe(query)
|
|
63
|
+
unless query
|
|
64
|
+
warn("harnex recipes show: recipe name required")
|
|
65
|
+
return 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
file = find_recipe(query)
|
|
69
|
+
unless file
|
|
70
|
+
warn("harnex recipes: no recipe matching #{query.inspect}")
|
|
71
|
+
warn("Run `harnex recipes list` to see available recipes.")
|
|
72
|
+
return 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
puts File.read(file)
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_recipe(query)
|
|
80
|
+
files = recipe_files
|
|
81
|
+
# Exact basename match (with or without .md)
|
|
82
|
+
exact = files.find { |f| File.basename(f, ".md") == query }
|
|
83
|
+
return exact if exact
|
|
84
|
+
|
|
85
|
+
# Prefix match (e.g. "01" matches "01_fire_and_watch")
|
|
86
|
+
prefix = files.find { |f| File.basename(f, ".md").start_with?(query) }
|
|
87
|
+
return prefix if prefix
|
|
88
|
+
|
|
89
|
+
# Substring match (e.g. "fire" matches "01_fire_and_watch")
|
|
90
|
+
files.find { |f| File.basename(f, ".md").include?(query) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def recipe_files
|
|
94
|
+
return [] unless Dir.exist?(RECIPES_DIR)
|
|
95
|
+
|
|
96
|
+
Dir.glob(File.join(RECIPES_DIR, "*.md")).sort
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_title(file)
|
|
100
|
+
first_line = File.foreach(file).first.to_s.strip
|
|
101
|
+
first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : ""
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|