latticerb 0.1.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.
@@ -0,0 +1,469 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ # Load the full library API so `require "lattice/cli"` alone is enough.
7
+ require_relative "../lattice"
8
+ require_relative "agent"
9
+ require_relative "constraints"
10
+ require_relative "formatters"
11
+ require_relative "ipog"
12
+ require_relative "parser"
13
+
14
+ module Lattice
15
+ # Command-line interface mirroring the Python `lattice` CLI: same
16
+ # subcommands, flags, defaults, output, and exit codes.
17
+ module CLI
18
+ DEFAULT_AGENT_FORMAT = "text"
19
+ SCHEMA_HELP =
20
+ "Schema features: constraints include invalid_pair, higher_order, " \
21
+ "forward_dep, bidirectional, conditional, and forced; parameters may use " \
22
+ "weights. Run `lattice agent instructions` for the full schema workflow."
23
+
24
+ extend self
25
+
26
+ def main(argv = ARGV)
27
+ command = argv.first
28
+ case command
29
+ when "generate" then run_generate(argv.drop(1))
30
+ when "validate" then run_validate(argv.drop(1))
31
+ when "agent" then run_agent(argv.drop(1))
32
+ when "--help", "-h", nil
33
+ print_usage(command.nil? ? $stderr : $stdout)
34
+ command.nil? ? 2 : 0
35
+ else
36
+ warn "lattice: unknown command `#{command}`"
37
+ print_usage($stderr)
38
+ 2
39
+ end
40
+ rescue OptionParser::ParseError => e
41
+ warn "lattice: #{e.message}"
42
+ 2
43
+ end
44
+
45
+ def print_usage(io)
46
+ io.puts <<~USAGE
47
+ usage: lattice {generate,validate,agent} ...
48
+
49
+ commands:
50
+ generate Generate deterministic scenarios from a schema.
51
+ validate Validate a schema without generating scenarios.
52
+ agent Install and describe coding-agent integrations.
53
+
54
+ #{SCHEMA_HELP}
55
+ USAGE
56
+ end
57
+
58
+ def run_generate(argv)
59
+ options = {
60
+ format: "json",
61
+ seed: 42,
62
+ pool_size: 50,
63
+ progress: false,
64
+ progress_every: 10
65
+ }
66
+ parser = OptionParser.new do |opts|
67
+ opts.on("--strength STRENGTH", Integer)
68
+ opts.on("--format FORMAT", %w[table json csv summary])
69
+ opts.on("--seed SEED", Integer)
70
+ opts.on("--pool-size SIZE", Integer) { |value| options[:pool_size] = value }
71
+ opts.on("--max-rows ROWS", Integer) { |value| options[:max_rows] = value }
72
+ opts.on("--stop-after-coverage PCT", Float) { |value| options[:stop_after_coverage] = value }
73
+ opts.on("--progress")
74
+ opts.on("--progress-every N", Integer) { |value| options[:progress_every] = value }
75
+ end
76
+ positional = parser.parse(argv, into: options)
77
+ model_path = positional.first
78
+
79
+ model = load_and_check_model(model_path, options)
80
+ return model if model.is_a?(Integer)
81
+
82
+ begin
83
+ progress_callback = options[:progress] ? build_progress_callback(options[:progress_every]) : nil
84
+ result = IPOG.generate_covering_array(
85
+ model,
86
+ strength: model.strength,
87
+ seed: options[:seed],
88
+ pool_size: options[:pool_size],
89
+ progress: progress_callback,
90
+ max_rows: options[:max_rows],
91
+ stop_after_coverage: options[:stop_after_coverage]
92
+ )
93
+ rescue ArgumentError => e
94
+ return emit_error(options, "generation_error", [e.message], exit_code: 1)
95
+ end
96
+
97
+ puts Formatters.render_output(model, result, output_format: options[:format], seed: options[:seed])
98
+ 0
99
+ end
100
+
101
+ def run_validate(argv)
102
+ options = { format: "json" }
103
+ parser = OptionParser.new do |opts|
104
+ opts.on("--strength STRENGTH", Integer)
105
+ opts.on("--format FORMAT", %w[json text])
106
+ end
107
+ positional = parser.parse(argv, into: options)
108
+ model_path = positional.first
109
+
110
+ model = load_and_check_model(model_path, options)
111
+ return model if model.is_a?(Integer)
112
+
113
+ print_validation_success(options, model)
114
+ 0
115
+ end
116
+
117
+ def load_and_check_model(model_path, options)
118
+ model = Parser.load_model(model_path, strength_override: options[:strength])
119
+ engine = ConstraintEngine.new(model)
120
+ raise ValidationError, ["Model has no valid complete assignments."] unless engine.can_complete?({})
121
+
122
+ model
123
+ rescue ValidationError => e
124
+ emit_error(options, "validation_error", e.errors, exit_code: 1)
125
+ rescue ConstraintError => e
126
+ emit_error(options, "constraint_error", [e.message], exit_code: 1)
127
+ rescue ModelIOError => e
128
+ emit_error(options, "io_error", [e.message], exit_code: 2)
129
+ end
130
+
131
+ def build_progress_callback(every)
132
+ raise ArgumentError, "--progress-every must be at least 1" if every < 1
133
+
134
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+
136
+ lambda do |progress|
137
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
138
+ case progress.event
139
+ when "enumerating"
140
+ eta = format_eta(elapsed, progress.covered, progress.total)
141
+ $stderr.puts(
142
+ "lattice progress: enumerating interactions " \
143
+ "checked=#{progress.covered}/#{progress.total} " \
144
+ "progress=#{format('%.1f', progress.cumulative_pct)}% " \
145
+ "elapsed=#{format_seconds(elapsed)} " \
146
+ "eta=#{eta}"
147
+ )
148
+ when "enumerated"
149
+ $stderr.puts(
150
+ "lattice progress: enumerated #{progress.total} valid interactions; starting generation"
151
+ )
152
+ else
153
+ return if progress.event == "scenario" && (progress.scenario % every) != 0 && progress.uncovered != 0
154
+
155
+ eta = format_eta(elapsed, progress.covered, progress.total)
156
+ label = progress.event == "stopped" ? "stopped " : ""
157
+ $stderr.puts(
158
+ "lattice progress: #{label}scenario=#{progress.scenario} " \
159
+ "covered=#{progress.covered}/#{progress.total} " \
160
+ "coverage=#{format('%.1f', progress.cumulative_pct)}% " \
161
+ "uncovered=#{progress.uncovered} " \
162
+ "elapsed=#{format_seconds(elapsed)} " \
163
+ "eta=#{eta}"
164
+ )
165
+ end
166
+ end
167
+ end
168
+
169
+ def format_eta(elapsed, covered, total)
170
+ return "unknown" if total <= 0 || covered <= 0
171
+ return "0s" if covered >= total
172
+
173
+ format_seconds(elapsed * ((total - covered).to_f / covered))
174
+ end
175
+
176
+ def format_seconds(seconds)
177
+ seconds = [0, seconds.round].max
178
+ minutes, sec = seconds.divmod(60)
179
+ hours, minutes = minutes.divmod(60)
180
+ return format("%dh%02dm%02ds", hours, minutes, sec) if hours.positive?
181
+ return format("%dm%02ds", minutes, sec) if minutes.positive?
182
+
183
+ "#{sec}s"
184
+ end
185
+
186
+ def emit_error(options, code, errors, exit_code:)
187
+ if options[:format] == "json"
188
+ puts JSON.pretty_generate({ "status" => "error", "code" => code, "errors" => errors }, ascii_only: true)
189
+ else
190
+ errors.each { |error| warn error }
191
+ end
192
+ exit_code
193
+ end
194
+
195
+ def print_validation_success(options, model)
196
+ if options[:format] == "json"
197
+ puts JSON.pretty_generate(
198
+ {
199
+ "status" => "ok",
200
+ "valid" => true,
201
+ "schema" => {
202
+ "name" => model.model_name,
203
+ "parameter_count" => model.parameters.length,
204
+ "constraint_count" => model.constraints.length,
205
+ "strength" => model.strength
206
+ }
207
+ },
208
+ ascii_only: true
209
+ )
210
+ return
211
+ end
212
+
213
+ puts [
214
+ "Schema is valid.",
215
+ "name: #{model.model_name}",
216
+ "parameters: #{model.parameters.length}",
217
+ "constraints: #{model.constraints.length}",
218
+ "strength: #{model.strength}"
219
+ ].join("\n")
220
+ end
221
+
222
+ def run_agent(argv)
223
+ command = argv.first
224
+ rest = argv.drop(1)
225
+ case command
226
+ when "bootstrap" then run_agent_bootstrap(rest)
227
+ when "install-codex-skill" then run_agent_install(rest, :codex)
228
+ when "install-skill" then run_agent_install_generic(rest)
229
+ when "install-claude-skill" then run_agent_install_claude(rest)
230
+ when "memory" then run_agent_memory(rest)
231
+ when "instructions" then run_agent_instructions(rest)
232
+ when "doctor" then run_agent_doctor(rest)
233
+ else
234
+ warn "Unknown agent command `#{command}`."
235
+ 2
236
+ end
237
+ end
238
+
239
+ def run_agent_bootstrap(argv)
240
+ options = { format: DEFAULT_AGENT_FORMAT }
241
+ parser = OptionParser.new do |opts|
242
+ opts.on("--target TARGET")
243
+ opts.on("--claude-target TARGET") { |value| options[:claude_target] = value }
244
+ opts.on("--force-codex") { options[:force_codex] = true }
245
+ opts.on("--skip-codex") { options[:skip_codex] = true }
246
+ opts.on("--skip-claude") { options[:skip_claude] = true }
247
+ opts.on("--force-claude") { options[:force_claude] = true }
248
+ opts.on("--format FORMAT", %w[text json])
249
+ end
250
+ parser.parse(argv, into: options)
251
+
252
+ codex_detected = Agent.codex_available?
253
+ claude_available = Agent.claude_code_available?
254
+ codex_skill_path = options[:target] ? File.expand_path(options[:target]) : Agent.default_codex_skill_path
255
+ claude_skill_path = options[:claude_target] ? File.expand_path(options[:claude_target]) : Agent.default_claude_skill_path
256
+
257
+ codex_setup, codex_skipped_reason = maybe_install_agent_skill(
258
+ detected: codex_detected,
259
+ force: options[:force_codex],
260
+ skip: options[:skip_codex],
261
+ skip_flag: "--skip-codex",
262
+ unavailable_reason: "Codex was not detected"
263
+ ) { Agent.install_codex_skill(options[:target]) }
264
+ claude_setup, claude_skipped_reason = maybe_install_agent_skill(
265
+ detected: claude_available,
266
+ force: options[:force_claude],
267
+ skip: options[:skip_claude],
268
+ skip_flag: "--skip-claude",
269
+ unavailable_reason: "Claude Code was not detected"
270
+ ) { Agent.install_claude_skill(options[:claude_target], force: options[:force_claude]) }
271
+
272
+ if options[:format] == "json"
273
+ payload = {
274
+ "status" => "ok",
275
+ "codex" => agent_surface_payload(
276
+ detected: codex_detected,
277
+ setup: codex_setup,
278
+ skill_path: codex_skill_path,
279
+ skipped_reason: codex_skipped_reason
280
+ ),
281
+ "claude" => agent_surface_payload(
282
+ detected: claude_available,
283
+ setup: claude_setup,
284
+ skill_path: claude_skill_path,
285
+ skipped_reason: claude_skipped_reason
286
+ ),
287
+ "memory" => Agent::MEMORY_SNIPPET,
288
+ "default_prompt" => Agent::DEFAULT_PROMPT,
289
+ "instructions_command" => "lattice agent instructions",
290
+ "generic_skill_command" => "lattice agent install-skill <target>"
291
+ }
292
+ puts JSON.pretty_generate(payload, ascii_only: true)
293
+ return 0
294
+ end
295
+
296
+ print_agent_install_status("Codex", codex_setup, codex_skipped_reason)
297
+ print_agent_install_status("Claude Code", claude_setup, claude_skipped_reason)
298
+ puts ""
299
+ puts "Agent memory:"
300
+ puts Agent::MEMORY_SNIPPET
301
+ puts ""
302
+ puts "Default prompt:"
303
+ puts Agent::DEFAULT_PROMPT
304
+ puts ""
305
+ puts "Any other harness:"
306
+ puts "Run `lattice agent instructions`, or `lattice agent install-skill <target>` if the harness accepts a SKILL.md directory."
307
+ 0
308
+ end
309
+
310
+ def run_agent_install(argv, _surface)
311
+ options = { format: DEFAULT_AGENT_FORMAT }
312
+ parser = OptionParser.new do |opts|
313
+ opts.on("--target TARGET")
314
+ opts.on("--format FORMAT", %w[text json])
315
+ end
316
+ parser.parse(argv, into: options)
317
+ setup = Agent.install_codex_skill(options[:target])
318
+ emit_agent_setup(setup, "Codex", options[:format])
319
+ end
320
+
321
+ def run_agent_install_generic(argv)
322
+ options = { format: DEFAULT_AGENT_FORMAT }
323
+ parser = OptionParser.new do |opts|
324
+ opts.on("--format FORMAT", %w[text json])
325
+ end
326
+ positional = parser.parse(argv, into: options)
327
+ target = positional.first
328
+ if target.nil?
329
+ warn "lattice: `agent install-skill` requires a target directory."
330
+ return 2
331
+ end
332
+ setup = Agent.install_generic_skill(target)
333
+ emit_agent_setup(setup, "generic", options[:format])
334
+ end
335
+
336
+ def run_agent_install_claude(argv)
337
+ options = { format: DEFAULT_AGENT_FORMAT }
338
+ parser = OptionParser.new do |opts|
339
+ opts.on("--target TARGET")
340
+ opts.on("--force")
341
+ opts.on("--format FORMAT", %w[text json])
342
+ end
343
+ parser.parse(argv, into: options)
344
+
345
+ begin
346
+ setup = Agent.install_claude_skill(options[:target], force: options[:force] || false)
347
+ rescue RuntimeError => e
348
+ if options[:format] == "json"
349
+ puts JSON.pretty_generate({ "status" => "unavailable", "error" => e.message }, ascii_only: true)
350
+ else
351
+ warn e.message
352
+ end
353
+ return 1
354
+ end
355
+
356
+ emit_agent_setup(setup, "Claude Code", options[:format], extra: { "available" => Agent.claude_code_available? })
357
+ end
358
+
359
+ def run_agent_memory(argv)
360
+ options = { format: DEFAULT_AGENT_FORMAT }
361
+ OptionParser.new { |opts| opts.on("--format FORMAT", %w[text json]) }.parse(argv, into: options)
362
+ if options[:format] == "json"
363
+ puts JSON.pretty_generate({ "memory" => Agent::MEMORY_SNIPPET }, ascii_only: true)
364
+ else
365
+ puts Agent::MEMORY_SNIPPET
366
+ end
367
+ 0
368
+ end
369
+
370
+ def run_agent_instructions(argv)
371
+ options = { format: DEFAULT_AGENT_FORMAT }
372
+ OptionParser.new { |opts| opts.on("--format FORMAT", %w[text json]) }.parse(argv, into: options)
373
+ if options[:format] == "json"
374
+ puts JSON.pretty_generate(
375
+ {
376
+ "instructions" => Agent::GENERIC_AGENT_INSTRUCTIONS,
377
+ "memory" => Agent::MEMORY_SNIPPET,
378
+ "default_prompt" => Agent::DEFAULT_PROMPT
379
+ },
380
+ ascii_only: true
381
+ )
382
+ else
383
+ puts Agent::GENERIC_AGENT_INSTRUCTIONS
384
+ end
385
+ 0
386
+ end
387
+
388
+ def run_agent_doctor(argv)
389
+ options = { format: DEFAULT_AGENT_FORMAT }
390
+ parser = OptionParser.new do |opts|
391
+ opts.on("--target TARGET")
392
+ opts.on("--claude-target TARGET") { |value| options[:claude_target] = value }
393
+ opts.on("--format FORMAT", %w[text json])
394
+ end
395
+ parser.parse(argv, into: options)
396
+
397
+ codex_path = options[:target] || Agent.default_codex_skill_path
398
+ claude_path = options[:claude_target] || Agent.default_claude_skill_path
399
+ codex_detected = Agent.codex_available?
400
+ codex_installed = Agent.codex_skill_installed?(options[:target])
401
+ claude_available = Agent.claude_code_available?
402
+ claude_installed = Agent.claude_skill_installed?(options[:claude_target])
403
+ known_harness_detected = codex_detected || claude_available
404
+ ok = (!codex_detected || codex_installed) && (!claude_available || claude_installed)
405
+
406
+ if options[:format] == "json"
407
+ puts JSON.pretty_generate(
408
+ {
409
+ "status" => ok ? "ok" : "missing",
410
+ "generic_instructions_available" => true,
411
+ "known_harness_detected" => known_harness_detected,
412
+ "codex_available" => codex_detected,
413
+ "codex_skill_installed" => codex_installed,
414
+ "codex_skill_path" => codex_path.to_s,
415
+ "claude_code_available" => claude_available,
416
+ "claude_skill_installed" => claude_installed,
417
+ "claude_skill_path" => claude_path.to_s
418
+ },
419
+ ascii_only: true
420
+ )
421
+ else
422
+ puts "Lattice agent setup"
423
+ puts "cli: ok"
424
+ puts "generic instructions: available (`lattice agent instructions`)"
425
+ puts "codex: #{codex_detected ? 'available' : 'not detected'}"
426
+ puts "codex skill: #{codex_installed ? 'installed' : 'missing'}"
427
+ puts "codex path: #{codex_path}"
428
+ puts "claude code: #{claude_available ? 'available' : 'not detected'}"
429
+ puts "claude skill: #{claude_installed ? 'installed' : 'missing'}"
430
+ puts "claude path: #{claude_path}"
431
+ end
432
+ ok ? 0 : 1
433
+ end
434
+
435
+ def maybe_install_agent_skill(detected:, force:, skip:, skip_flag:, unavailable_reason:)
436
+ return [nil, "skipped by #{skip_flag}"] if skip
437
+ return [yield, nil] if detected || force
438
+
439
+ [nil, unavailable_reason]
440
+ end
441
+
442
+ def agent_surface_payload(detected:, setup:, skill_path:, skipped_reason:)
443
+ {
444
+ "available" => detected,
445
+ "installed" => !setup.nil?,
446
+ "skill_path" => (setup ? setup.skill_path : skill_path).to_s,
447
+ "skipped_reason" => skipped_reason
448
+ }
449
+ end
450
+
451
+ def emit_agent_setup(setup, label, output_format, extra: nil)
452
+ if output_format == "json"
453
+ puts JSON.pretty_generate({ "status" => "ok" }.merge(extra || {}).merge(setup.to_json_payload), ascii_only: true)
454
+ return 0
455
+ end
456
+
457
+ puts "Installed `#{setup.skill_name}` #{label} skill at #{setup.skill_path}"
458
+ 0
459
+ end
460
+
461
+ def print_agent_install_status(label, setup, skipped_reason)
462
+ if setup
463
+ puts "Installed `#{setup.skill_name}` #{label} skill at #{setup.skill_path}"
464
+ elsif skipped_reason
465
+ puts "#{label} skill: not installed (#{skipped_reason})"
466
+ end
467
+ end
468
+ end
469
+ end