mixin_bot 1.4.0 → 2.0.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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +75 -0
  3. data/API_COVERAGE.md +143 -0
  4. data/CHANGELOG.md +112 -0
  5. data/README.md +375 -0
  6. data/docs/agent/cli.md +149 -0
  7. data/docs/agent/cookbook.md +152 -0
  8. data/examples/blaze.rb +43 -0
  9. data/examples/config.yml.example +21 -0
  10. data/lib/mixin_bot/address.rb +43 -3
  11. data/lib/mixin_bot/api/app.rb +7 -0
  12. data/lib/mixin_bot/api/asset.rb +114 -3
  13. data/lib/mixin_bot/api/auth.rb +19 -10
  14. data/lib/mixin_bot/api/blaze.rb +81 -0
  15. data/lib/mixin_bot/api/chain.rb +94 -0
  16. data/lib/mixin_bot/api/code.rb +16 -0
  17. data/lib/mixin_bot/api/computer_api.rb +60 -0
  18. data/lib/mixin_bot/api/conversation.rb +7 -1
  19. data/lib/mixin_bot/api/deposit.rb +12 -0
  20. data/lib/mixin_bot/api/encrypted_message.rb +1 -1
  21. data/lib/mixin_bot/api/fiat.rb +12 -0
  22. data/lib/mixin_bot/api/inscription.rb +2 -2
  23. data/lib/mixin_bot/api/legacy_collectible.rb +26 -27
  24. data/lib/mixin_bot/api/legacy_multisig.rb +20 -21
  25. data/lib/mixin_bot/api/legacy_output.rb +10 -3
  26. data/lib/mixin_bot/api/legacy_payment.rb +2 -0
  27. data/lib/mixin_bot/api/legacy_snapshot.rb +16 -0
  28. data/lib/mixin_bot/api/legacy_transaction.rb +28 -13
  29. data/lib/mixin_bot/api/legacy_transfer.rb +11 -8
  30. data/lib/mixin_bot/api/legacy_user.rb +51 -0
  31. data/lib/mixin_bot/api/me.rb +99 -3
  32. data/lib/mixin_bot/api/message.rb +18 -27
  33. data/lib/mixin_bot/api/multisig.rb +19 -0
  34. data/lib/mixin_bot/api/network.rb +17 -0
  35. data/lib/mixin_bot/api/network_asset.rb +27 -0
  36. data/lib/mixin_bot/api/output.rb +1 -1
  37. data/lib/mixin_bot/api/pin.rb +16 -3
  38. data/lib/mixin_bot/api/pin_payload.rb +26 -0
  39. data/lib/mixin_bot/api/session.rb +14 -0
  40. data/lib/mixin_bot/api/snapshot.rb +6 -0
  41. data/lib/mixin_bot/api/tip.rb +74 -1
  42. data/lib/mixin_bot/api/transaction.rb +106 -17
  43. data/lib/mixin_bot/api/transfer.rb +141 -14
  44. data/lib/mixin_bot/api/turn.rb +12 -0
  45. data/lib/mixin_bot/api/user.rb +148 -45
  46. data/lib/mixin_bot/api/withdraw.rb +24 -23
  47. data/lib/mixin_bot/api.rb +248 -3
  48. data/lib/mixin_bot/bot_auth.rb +71 -0
  49. data/lib/mixin_bot/cli/api.rb +224 -143
  50. data/lib/mixin_bot/cli/base.rb +77 -0
  51. data/lib/mixin_bot/cli/call.rb +71 -0
  52. data/lib/mixin_bot/cli/errors.rb +56 -0
  53. data/lib/mixin_bot/cli/node.rb +9 -2
  54. data/lib/mixin_bot/cli/output.rb +196 -0
  55. data/lib/mixin_bot/cli/schema.rb +274 -0
  56. data/lib/mixin_bot/cli/schema_command.rb +21 -0
  57. data/lib/mixin_bot/cli/utils.rb +114 -18
  58. data/lib/mixin_bot/cli.rb +124 -48
  59. data/lib/mixin_bot/client/error_mapper.rb +40 -0
  60. data/lib/mixin_bot/client.rb +94 -64
  61. data/lib/mixin_bot/computer.rb +132 -0
  62. data/lib/mixin_bot/configuration.rb +108 -1
  63. data/lib/mixin_bot/errors.rb +102 -0
  64. data/lib/mixin_bot/models/address.rb +11 -0
  65. data/lib/mixin_bot/models/api_envelope.rb +67 -0
  66. data/lib/mixin_bot/models/asset.rb +11 -0
  67. data/lib/mixin_bot/models/ghost_keys.rb +14 -0
  68. data/lib/mixin_bot/models/output.rb +11 -0
  69. data/lib/mixin_bot/models/safe_multisig_request.rb +11 -0
  70. data/lib/mixin_bot/models/sequencer_transaction_request.rb +11 -0
  71. data/lib/mixin_bot/models/user.rb +11 -0
  72. data/lib/mixin_bot/models.rb +10 -0
  73. data/lib/mixin_bot/monitor.rb +77 -0
  74. data/lib/mixin_bot/transaction/buffer.rb +34 -0
  75. data/lib/mixin_bot/transaction/decoder.rb +227 -0
  76. data/lib/mixin_bot/transaction/encoder.rb +255 -0
  77. data/lib/mixin_bot/transaction.rb +6 -475
  78. data/lib/mixin_bot/url_scheme.rb +63 -0
  79. data/lib/mixin_bot/utils/address.rb +17 -80
  80. data/lib/mixin_bot/utils/crypto.rb +173 -1
  81. data/lib/mixin_bot/utils/decoder.rb +1 -1
  82. data/lib/mixin_bot/utils/encoder.rb +13 -0
  83. data/lib/mixin_bot/utils.rb +45 -0
  84. data/lib/mixin_bot/uuid.rb +78 -1
  85. data/lib/mixin_bot/version.rb +11 -1
  86. data/lib/mixin_bot.rb +172 -18
  87. data/lib/mvm/bridge.rb +46 -0
  88. data/lib/mvm/client.rb +60 -0
  89. data/lib/mvm/nft.rb +4 -2
  90. data/lib/mvm/registry.rb +2 -1
  91. data/lib/mvm.rb +93 -0
  92. data/lib/tasks/api_coverage.rake +20 -0
  93. data/llms.txt +29 -0
  94. metadata +77 -9
@@ -34,7 +34,7 @@ module MixinBot
34
34
  '-c',
35
35
  c.to_s
36
36
  )
37
- distributions = eval o
37
+ distributions = parse_mixin_cli_output(o)
38
38
  spinner.update_title "#{distributions.size} mint distributions listed"
39
39
  end
40
40
 
@@ -54,7 +54,7 @@ module MixinBot
54
54
  '-x',
55
55
  tx
56
56
  )
57
- tx = eval o
57
+ tx = parse_mixin_cli_output(o)
58
58
  spinner.update_title "#{tx[:outputs].size} transaction outputs found"
59
59
  end
60
60
 
@@ -92,6 +92,13 @@ module MixinBot
92
92
  $CHILD_STATUS.success?
93
93
  end
94
94
 
95
+ def parse_mixin_cli_output(output)
96
+ JSON.parse(output, symbolize_names: true)
97
+ rescue JSON::ParserError
98
+ # Mixin node CLI historically prints Ruby hash literals.
99
+ eval(output)
100
+ end
101
+
95
102
  def log(obj)
96
103
  if options[:pretty]
97
104
  if obj.is_a? String
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module MixinBot
7
+ ##
8
+ # Structured and human-friendly stdout/stderr formatting for mixinbot.
9
+ #
10
+ module CLIOutput
11
+ OUTPUT_FORMATS = %w[pretty json yaml].freeze
12
+
13
+ def cli_options
14
+ merged = options.dup
15
+ merged = parent_options.merge(merged) if respond_to?(:parent_options) && parent_options.present?
16
+ merged
17
+ end
18
+
19
+ def output_format
20
+ opts = cli_options
21
+ explicit = opts[:output].to_s.downcase if opts[:output].present?
22
+ return explicit if OUTPUT_FORMATS.include?(explicit)
23
+
24
+ return 'json' if opts.key?(:pretty) && opts[:pretty] == false
25
+
26
+ $stdout.tty? ? 'pretty' : 'json'
27
+ end
28
+
29
+ def structured_output?
30
+ %w[json yaml].include?(output_format)
31
+ end
32
+
33
+ def current_command_name
34
+ @current_command_name || self.class.name.split('::').last.sub(/CLI\z/, '').downcase
35
+ end
36
+
37
+ def with_command_name(name)
38
+ previous = @current_command_name
39
+ @current_command_name = name
40
+ yield
41
+ ensure
42
+ @current_command_name = previous
43
+ end
44
+
45
+ def emit_success(data, command: nil)
46
+ command_name = command || current_command_name
47
+ if structured_output?
48
+ write_stdout(encode_output(envelope('ok', command_name, data)))
49
+ else
50
+ write_pretty(data)
51
+ end
52
+ end
53
+
54
+ def emit_list(items:, total:, limit:, offset:, command: 'list')
55
+ payload = {
56
+ 'items' => items,
57
+ 'total' => total,
58
+ 'limit' => limit,
59
+ 'offset' => offset
60
+ }
61
+ emit_success(payload, command:)
62
+ end
63
+
64
+ def emit_info(message)
65
+ return if message.blank?
66
+
67
+ if structured_output?
68
+ warn(message)
69
+ else
70
+ warn(format_info(message))
71
+ end
72
+ end
73
+
74
+ def abort_with_error(message, kind: nil, hint: nil, exception: nil)
75
+ resolved_kind = kind || (exception && CLIErrors.kind_for_exception(exception)) || CLIErrors.kind_for_message(message)
76
+ hint ||= default_error_hint
77
+
78
+ if structured_output?
79
+ error_body = {
80
+ 'status' => 'error',
81
+ 'error' => {
82
+ 'kind' => resolved_kind.to_s,
83
+ 'message' => message.to_s
84
+ }
85
+ }
86
+ error_body['error']['hint'] = hint if hint.present?
87
+ warn(JSON.generate(error_body))
88
+ else
89
+ warn(format_error(message))
90
+ end
91
+ exit(1)
92
+ end
93
+
94
+ def print_result(obj, data_only: false, command: nil)
95
+ out =
96
+ case obj
97
+ when MixinBot::Models::ApiEnvelope
98
+ data_only ? (obj['data'] || obj.to_h) : obj.to_h
99
+ when Hash
100
+ data_only ? (obj['data'] || obj) : obj
101
+ else
102
+ obj
103
+ end
104
+
105
+ emit_success(out, command:)
106
+ end
107
+
108
+ def log(obj)
109
+ emit_success(obj)
110
+ end
111
+
112
+ def paginate_items(items, limit:, offset:)
113
+ limit = limit.to_i
114
+ offset = offset.to_i
115
+ limit = 100 if limit <= 0
116
+ offset = 0 if offset.negative?
117
+
118
+ total = items.size
119
+ slice = items.drop(offset).first(limit)
120
+ [slice, total, limit, offset]
121
+ end
122
+
123
+ def select_fields(items, fields)
124
+ return items if fields.blank?
125
+
126
+ keys = fields.split(',').map(&:strip).reject(&:empty?)
127
+ return items if keys.empty?
128
+
129
+ items.map do |item|
130
+ item.slice(*keys)
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def envelope(status, command, data)
137
+ {
138
+ 'status' => status,
139
+ 'command' => command,
140
+ 'data' => normalize_data(data)
141
+ }
142
+ end
143
+
144
+ def normalize_data(data)
145
+ case data
146
+ when MixinBot::Models::ApiEnvelope
147
+ data.to_h
148
+ when Hash
149
+ data.transform_keys(&:to_s)
150
+ else
151
+ data
152
+ end
153
+ end
154
+
155
+ def encode_output(payload)
156
+ case output_format
157
+ when 'yaml'
158
+ payload.to_yaml
159
+ else
160
+ JSON.generate(payload)
161
+ end
162
+ end
163
+
164
+ def write_stdout(text)
165
+ puts text
166
+ end
167
+
168
+ def write_pretty(obj)
169
+ if obj.is_a?(String)
170
+ puts obj
171
+ else
172
+ ap obj
173
+ end
174
+ end
175
+
176
+ def format_info(message)
177
+ if defined?(UI) && UI.respond_to?(:fmt)
178
+ UI.fmt(message.to_s)
179
+ else
180
+ message.to_s
181
+ end
182
+ end
183
+
184
+ def format_error(message)
185
+ if defined?(UI) && UI.respond_to?(:fmt)
186
+ UI.fmt("{{x}} #{message}")
187
+ else
188
+ "Error: #{message}"
189
+ end
190
+ end
191
+
192
+ def default_error_hint
193
+ 'Run `mixinbot help` or `mixinbot schema -o json` for usage'
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ ##
5
+ # Builds clispec-shaped schema documents for mixinbot.
6
+ #
7
+ module CLISchema
8
+ MUTATING_COMMANDS = %w[
9
+ transfer legacy-transfer safetransfer authcode updatetip saferegister pay
10
+ ].freeze
11
+
12
+ MUTATING_API_METHOD_PREFIXES = %w[
13
+ create_ update_ delete_ send_ post_ authorize_ safe_register migrate_
14
+ build_inscribe transfer_app upload_
15
+ ].freeze
16
+
17
+ COMMAND_DEFINITIONS = [
18
+ {
19
+ 'name' => 'version',
20
+ 'description' => 'Display MixinBot gem version',
21
+ 'mutating' => false,
22
+ 'args' => [],
23
+ 'output_fields' => [
24
+ { 'name' => 'version', 'type' => 'string' }
25
+ ]
26
+ },
27
+ {
28
+ 'name' => 'schema',
29
+ 'description' => 'Emit machine-readable CLI schema (clispec-shaped)',
30
+ 'mutating' => false,
31
+ 'args' => [
32
+ { 'name' => '--output', 'type' => 'string', 'required' => false,
33
+ 'enum' => %w[pretty json yaml], 'default' => nil }
34
+ ],
35
+ 'output_fields' => [
36
+ { 'name' => 'name', 'type' => 'string' },
37
+ { 'name' => 'version', 'type' => 'string' },
38
+ { 'name' => 'commands', 'type' => 'array' },
39
+ { 'name' => 'errors', 'type' => 'array' }
40
+ ]
41
+ },
42
+ {
43
+ 'name' => 'list',
44
+ 'description' => 'List callable MixinBot::API methods',
45
+ 'mutating' => false,
46
+ 'args' => [
47
+ { 'name' => 'FILTER', 'type' => 'string', 'required' => false },
48
+ { 'name' => '--limit', 'type' => 'integer', 'required' => false, 'default' => 100 },
49
+ { 'name' => '--offset', 'type' => 'integer', 'required' => false, 'default' => 0 },
50
+ { 'name' => '--fields', 'type' => 'string', 'required' => false,
51
+ 'description' => 'Comma-separated fields: name,owner' }
52
+ ],
53
+ 'output_fields' => [
54
+ { 'name' => 'items', 'type' => 'array' },
55
+ { 'name' => 'total', 'type' => 'integer' },
56
+ { 'name' => 'limit', 'type' => 'integer' },
57
+ { 'name' => 'offset', 'type' => 'integer' }
58
+ ]
59
+ },
60
+ {
61
+ 'name' => 'call',
62
+ 'description' => 'Invoke a MixinBot::API method with JSON keyword arguments',
63
+ 'mutating' => 'conditional',
64
+ 'mutating_note' => 'Mutating when METHOD matches write operations; use `mixinbot list` to inspect',
65
+ 'args' => [
66
+ { 'name' => 'METHOD', 'type' => 'string', 'required' => true },
67
+ { 'name' => '--keystore', 'type' => 'string', 'required' => false, 'aliases' => ['-k'] },
68
+ { 'name' => '--data', 'type' => 'string', 'required' => false, 'default' => '{}', 'aliases' => ['-d'] },
69
+ { 'name' => '--data-only', 'type' => 'boolean', 'required' => false, 'default' => false }
70
+ ],
71
+ 'output_fields' => [
72
+ { 'name' => 'data', 'type' => 'object | array | string' }
73
+ ]
74
+ },
75
+ {
76
+ 'name' => 'api',
77
+ 'description' => 'Signed GET/POST request to a Mixin API path',
78
+ 'mutating' => 'conditional',
79
+ 'mutating_note' => 'Mutating when --method POST',
80
+ 'args' => [
81
+ { 'name' => 'PATH', 'type' => 'string', 'required' => true },
82
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] },
83
+ { 'name' => '--method', 'type' => 'string', 'required' => false, 'default' => 'GET', 'aliases' => ['-m'] },
84
+ { 'name' => '--data', 'type' => 'string', 'required' => false, 'default' => '{}', 'aliases' => ['-d'] },
85
+ { 'name' => '--data-only', 'type' => 'boolean', 'required' => false, 'default' => true }
86
+ ]
87
+ },
88
+ {
89
+ 'name' => 'transfer',
90
+ 'description' => 'Safe transfer to USER_ID',
91
+ 'mutating' => true,
92
+ 'args' => [
93
+ { 'name' => 'USER_ID', 'type' => 'string', 'required' => true },
94
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] },
95
+ { 'name' => '--asset', 'type' => 'string', 'required' => true },
96
+ { 'name' => '--amount', 'type' => 'string', 'required' => true },
97
+ { 'name' => '--memo', 'type' => 'string', 'required' => false },
98
+ { 'name' => '--trace', 'type' => 'string', 'required' => false },
99
+ { 'name' => '--spend-key', 'type' => 'string', 'required' => false },
100
+ { 'name' => '--dry-run', 'type' => 'boolean', 'required' => false, 'default' => false }
101
+ ]
102
+ },
103
+ {
104
+ 'name' => 'legacy-transfer',
105
+ 'description' => 'Legacy POST /transfers (deprecated)',
106
+ 'mutating' => true,
107
+ 'args' => [
108
+ { 'name' => 'USER_ID', 'type' => 'string', 'required' => true },
109
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] },
110
+ { 'name' => '--asset', 'type' => 'string', 'required' => true },
111
+ { 'name' => '--amount', 'type' => 'number', 'required' => true }
112
+ ]
113
+ },
114
+ {
115
+ 'name' => 'safetransfer',
116
+ 'description' => 'Alias for transfer (deprecated)',
117
+ 'mutating' => true,
118
+ 'args' => []
119
+ },
120
+ {
121
+ 'name' => 'authcode',
122
+ 'description' => 'OAuth authorization code',
123
+ 'mutating' => true,
124
+ 'args' => [
125
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] },
126
+ { 'name' => '--app-id', 'type' => 'string', 'required' => true, 'aliases' => ['-c'] }
127
+ ]
128
+ },
129
+ {
130
+ 'name' => 'saferegister',
131
+ 'description' => 'Register on SAFE network',
132
+ 'mutating' => true,
133
+ 'args' => [
134
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] },
135
+ { 'name' => '--spend-key', 'type' => 'string', 'required' => true }
136
+ ]
137
+ },
138
+ {
139
+ 'name' => 'pay',
140
+ 'description' => 'Generate Safe payment URL',
141
+ 'mutating' => false,
142
+ 'args' => [
143
+ { 'name' => '--members', 'type' => 'array', 'required' => true },
144
+ { 'name' => '--asset', 'type' => 'string', 'required' => true },
145
+ { 'name' => '--amount', 'type' => 'string', 'required' => true }
146
+ ]
147
+ },
148
+ {
149
+ 'name' => 'encrypt',
150
+ 'description' => 'Encrypt PIN using session keys',
151
+ 'mutating' => false,
152
+ 'args' => [
153
+ { 'name' => 'PIN', 'type' => 'string', 'required' => true },
154
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] }
155
+ ]
156
+ },
157
+ {
158
+ 'name' => 'verifypin',
159
+ 'description' => 'Verify PIN',
160
+ 'mutating' => false,
161
+ 'args' => [
162
+ { 'name' => 'PIN', 'type' => 'string', 'required' => true },
163
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] }
164
+ ]
165
+ },
166
+ {
167
+ 'name' => 'updatetip',
168
+ 'description' => 'Update TIP PIN',
169
+ 'mutating' => true,
170
+ 'args' => [
171
+ { 'name' => 'PIN', 'type' => 'string', 'required' => true },
172
+ { 'name' => '--keystore', 'type' => 'string', 'required' => true, 'aliases' => ['-k'] }
173
+ ]
174
+ },
175
+ {
176
+ 'name' => 'utils',
177
+ 'description' => 'Utils subcommands (call, list)',
178
+ 'mutating' => false,
179
+ 'args' => []
180
+ },
181
+ {
182
+ 'name' => 'utils call',
183
+ 'description' => 'Invoke a MixinBot.utils method',
184
+ 'mutating' => false,
185
+ 'args' => [
186
+ { 'name' => 'METHOD', 'type' => 'string', 'required' => true },
187
+ { 'name' => '--data', 'type' => 'string', 'required' => false, 'default' => '{}', 'aliases' => ['-d'] }
188
+ ]
189
+ },
190
+ {
191
+ 'name' => 'utils list',
192
+ 'description' => 'List callable MixinBot.utils methods',
193
+ 'mutating' => false,
194
+ 'args' => [
195
+ { 'name' => 'FILTER', 'type' => 'string', 'required' => false },
196
+ { 'name' => '--limit', 'type' => 'integer', 'required' => false, 'default' => 100 },
197
+ { 'name' => '--offset', 'type' => 'integer', 'required' => false, 'default' => 0 },
198
+ { 'name' => '--fields', 'type' => 'string', 'required' => false, 'default' => 'name' }
199
+ ]
200
+ },
201
+ {
202
+ 'name' => 'unique',
203
+ 'description' => 'Deterministic UUID from two or more UUIDs',
204
+ 'mutating' => false,
205
+ 'args' => [{ 'name' => 'UUIDS', 'type' => 'array', 'required' => true }]
206
+ },
207
+ {
208
+ 'name' => 'generatetrace',
209
+ 'description' => 'Trace UUID from transaction hash',
210
+ 'mutating' => false,
211
+ 'args' => [{ 'name' => 'HASH', 'type' => 'string', 'required' => true }]
212
+ },
213
+ {
214
+ 'name' => 'decodetx',
215
+ 'description' => 'Decode raw transaction hex',
216
+ 'mutating' => false,
217
+ 'args' => [{ 'name' => 'TRANSACTION', 'type' => 'string', 'required' => true }]
218
+ },
219
+ {
220
+ 'name' => 'nftmemo',
221
+ 'description' => 'NFT mint memo',
222
+ 'mutating' => false,
223
+ 'args' => []
224
+ },
225
+ {
226
+ 'name' => 'rsa',
227
+ 'description' => 'Generate RSA key pair',
228
+ 'mutating' => false,
229
+ 'args' => []
230
+ },
231
+ {
232
+ 'name' => 'ed25519',
233
+ 'description' => 'Generate Ed25519 key pair',
234
+ 'mutating' => false,
235
+ 'args' => []
236
+ }
237
+ ].freeze
238
+
239
+ module_function
240
+
241
+ def build
242
+ {
243
+ 'name' => 'mixinbot',
244
+ 'version' => MixinBot::VERSION,
245
+ 'license' => 'MIT',
246
+ 'commands' => commands_with_globals,
247
+ 'errors' => CLIErrors.schema_errors,
248
+ 'api_method_count' => CLIHelpers.api_callable_methods.size,
249
+ 'api_method_registry' => 'mixinbot list -o json'
250
+ }
251
+ end
252
+
253
+ def commands_with_globals
254
+ global_args = [
255
+ { 'name' => '--apihost', 'type' => 'string', 'required' => false, 'default' => 'api.mixin.one',
256
+ 'aliases' => ['-a'] },
257
+ { 'name' => '--output', 'type' => 'string', 'required' => false,
258
+ 'enum' => CLIOutput::OUTPUT_FORMATS, 'aliases' => ['-o'],
259
+ 'description' => 'Output format; defaults to pretty in TTY, json when piped' },
260
+ { 'name' => '--pretty', 'type' => 'boolean', 'required' => false, 'default' => true,
261
+ 'aliases' => ['-r'], 'description' => 'Alias for --output pretty' }
262
+ ]
263
+
264
+ COMMAND_DEFINITIONS.map do |cmd|
265
+ cmd.merge('global_args' => global_args)
266
+ end
267
+ end
268
+
269
+ def mutating_api_method?(method_name)
270
+ name = method_name.to_s
271
+ MUTATING_API_METHOD_PREFIXES.any? { |prefix| name.start_with?(prefix) }
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MixinBot
4
+ class CLI
5
+ desc 'schema', 'Emit machine-readable CLI schema (clispec-shaped)'
6
+ long_desc <<-LONGDESC
7
+ Discover mixinbot commands, arguments, and error kinds without parsing --help.
8
+
9
+ Examples:
10
+
11
+ $ mixinbot schema -o json
12
+ $ mixinbot schema -o json | jq '.commands[].name'
13
+ $ mixinbot schema -o yaml
14
+ LONGDESC
15
+ def schema
16
+ with_command_name('schema') do
17
+ emit_success(CLISchema.build, command: 'schema')
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,45 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MixinBot
4
- class CLI < Thor
5
- desc 'encrypt PIN', 'encrypt PIN using private key'
4
+ class UtilsCLI < Thor
5
+ include CLIOutput
6
+ include CLIHelpers
7
+
8
+ desc 'call METHOD', 'Invoke a MixinBot.utils method'
9
+ option :data, type: :string, aliases: '-d', default: '{}', desc: 'JSON object of keyword arguments'
10
+ def call(method_name, *positional)
11
+ with_command_name('utils call') do
12
+ kwargs = parse_utils_json_data(options[:data])
13
+ result = invoke_utils_method(method_name, kwargs:, positional:)
14
+ emit_success(result, command: 'utils call')
15
+ end
16
+ end
17
+
18
+ desc 'list [FILTER]', 'List callable MixinBot.utils methods'
19
+ option :limit, type: :numeric, default: 100, desc: 'Maximum items to return'
20
+ option :offset, type: :numeric, default: 0, desc: 'Number of items to skip'
21
+ option :fields, type: :string, default: 'name', desc: 'Comma-separated fields for JSON output'
22
+ def list(filter = nil)
23
+ with_command_name('utils list') do
24
+ methods = CLIHelpers.utils_callable_methods
25
+ if filter.present?
26
+ needle = filter.downcase
27
+ methods = methods.select { |m| m.to_s.downcase.include?(needle) }
28
+ end
29
+
30
+ items = methods.map { |name| { 'name' => name.to_s } }
31
+ page, total, limit, offset = paginate_items(items, limit: options[:limit], offset: options[:offset])
32
+ page = select_fields(page, options[:fields])
33
+
34
+ if structured_output?
35
+ emit_list(items: page, total:, limit:, offset:, command: 'utils list')
36
+ else
37
+ page.each { |item| puts item['name'] }
38
+ emit_info("Showing #{page.size} of #{total} (limit=#{limit}, offset=#{offset})") if total > limit || offset.positive?
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def parse_utils_json_data(json_string, label: 'data')
46
+ return {} if json_string.blank?
47
+
48
+ parsed = JSON.parse(json_string)
49
+ abort_with_error("#{label} must be a JSON object", kind: :invalid_args) unless parsed.is_a?(Hash)
50
+
51
+ parsed.transform_keys(&:to_sym)
52
+ rescue JSON::ParserError => e
53
+ abort_with_error("invalid JSON for #{label}: #{e.message}", kind: :invalid_args)
54
+ end
55
+
56
+ def invoke_utils_method(method_name, kwargs: {}, positional: [])
57
+ sym = method_name.to_sym
58
+ unless CLIHelpers.utils_callable_methods.include?(sym)
59
+ abort_with_error(
60
+ format('unknown utils method: %<method>s', method: method_name),
61
+ kind: :unsupported,
62
+ hint: 'mixinbot utils list'
63
+ )
64
+ end
65
+
66
+ MixinBot.utils.public_send(sym, *positional, **kwargs)
67
+ rescue ::ArgumentError => e
68
+ abort_with_error(
69
+ format('invalid arguments for utils.%<method>s: %<error>s', method: method_name, error: e.message),
70
+ kind: :invalid_args
71
+ )
72
+ end
73
+ end
74
+
75
+ class CLI
76
+ desc 'utils', 'Utils dispatcher (call, list)'
77
+ subcommand 'utils', UtilsCLI
78
+
79
+ desc 'encrypt PIN', 'Encrypt PIN using session keys'
6
80
  option :keystore, type: :string, aliases: '-k', required: true, desc: 'keystore or keystore.json file path'
7
81
  option :iterator, type: :string, aliases: '-i', desc: 'Iterator'
8
82
  def encrypt(pin)
9
- log api_instance.encrypt_pin pin.to_s, iterator: options[:iterator]
83
+ with_command_name('encrypt') do
84
+ setup_api_instance!
85
+ emit_success(api_instance.encrypt_pin(pin.to_s, iterator: options[:iterator]), command: 'encrypt')
86
+ end
10
87
  end
11
88
 
12
- desc 'unique UUIDS', 'generate unique UUID for two or more UUIDs'
89
+ desc 'unique UUIDS', 'Deterministic UUID from two or more UUIDs'
13
90
  def unique(*uuids)
14
- log MixinBot.utils.unique_uuid(*uuids)
91
+ with_command_name('unique') do
92
+ emit_success(MixinBot.utils.unique_uuid(*uuids), command: 'unique')
93
+ end
15
94
  end
16
95
 
17
- desc 'generatetrace HASH', 'generate trace ID from Tx hash'
96
+ desc 'generatetrace HASH', 'Trace UUID from transaction hash'
97
+ option :index, type: :numeric, default: 0, desc: 'Output index'
18
98
  def generatetrace(hash)
19
- log MixinBot.utils.generate_trace_from_hash(hash)
99
+ with_command_name('generatetrace') do
100
+ emit_success(
101
+ MixinBot.utils.generate_trace_from_hash(hash, options[:index]),
102
+ command: 'generatetrace'
103
+ )
104
+ end
20
105
  end
21
106
 
22
- desc 'decodetx TRANSACTION', 'decode raw transaction'
107
+ desc 'decodetx TRANSACTION', 'Decode raw transaction hex'
23
108
  def decodetx(transaction)
24
- log MixinBot.utils.decode_raw_transaction(transaction)
109
+ with_command_name('decodetx') do
110
+ emit_success(MixinBot.utils.decode_raw_transaction(transaction), command: 'decodetx')
111
+ end
25
112
  end
26
113
 
27
- desc 'nftmemo', 'memo for mint NFT'
28
- option :collection, type: :string, required: true, aliases: '-c', desc: 'Collection ID, UUID'
29
- option :token, type: :numeric, required: true, aliases: '-t', desc: 'Token ID, Integer'
30
- option :hash, type: :string, required: true, aliases: '-h', desc: 'Hash of NFT metadata, 256-bit string'
114
+ desc 'nftmemo', 'NFT mint memo'
115
+ option :collection, type: :string, required: true, aliases: '-c', desc: 'Collection ID (UUID)'
116
+ option :token, type: :numeric, required: true, aliases: '-t', desc: 'Token ID'
117
+ option :hash, type: :string, required: true, aliases: '-h', desc: 'Metadata hash (256-bit hex)'
31
118
  def nftmemo
32
- log MixinBot.utils.nft(options[:collection], options[:token], options[:hash])
119
+ with_command_name('nftmemo') do
120
+ emit_success(
121
+ MixinBot.utils.nft_memo(options[:collection], options[:token], options[:hash]),
122
+ command: 'nftmemo'
123
+ )
124
+ end
33
125
  end
34
126
 
35
- desc 'rsa', 'generate RSA key'
127
+ desc 'rsa', 'Generate RSA key pair'
36
128
  def rsa
37
- log MixinBot.utils.generate_rsa_key
129
+ with_command_name('rsa') do
130
+ emit_success(MixinBot.utils.generate_rsa_key, command: 'rsa')
131
+ end
38
132
  end
39
133
 
40
- desc 'ed25519', 'generate Ed25519 key'
134
+ desc 'ed25519', 'Generate Ed25519 key pair'
41
135
  def ed25519
42
- log MixinBot.utils.generate_ed25519_key
136
+ with_command_name('ed25519') do
137
+ emit_success(MixinBot.utils.generate_ed25519_key, command: 'ed25519')
138
+ end
43
139
  end
44
140
  end
45
141
  end