number_station 0.2.0 → 0.4.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.
@@ -21,193 +21,1611 @@
21
21
 
22
22
  require 'thor'
23
23
  require 'fileutils'
24
+ require 'date'
25
+ require 'json'
24
26
 
25
27
  module NumberStation
26
- class CLI < Thor
28
+ class Pad < Thor
29
+ # Remove the built-in 'tree' command
30
+ def self.all_commands
31
+ super.reject { |k, v| k == 'tree' }
32
+ end
33
+
34
+ desc "create [--name NAME --path PATH --numpads NUM --length LENGTH]", "Generate a one time pad of LENGTH containing NUM entries"
35
+ long_desc <<-CREATE_PAD_LONG_DESC
36
+ Generate a one time pad of LENGTH containing NUM entries
37
+
38
+ Parameters:
39
+ --name NAME - Agent name to associate with this pad. Creates subdirectory ~/number_station/pads/NAME/ if --path is not provided
40
+ --path PATH - Directory where the pad file will be created (defaults to current directory, or ~/number_station/pads/NAME/ if --name is provided)
41
+ --numpads NUM - Number of pads to generate (defaults to 500)
42
+ --length LENGTH - Length of each pad in characters (defaults to 500)
43
+
44
+ If no parameters are passed it will generate 500 one time pads in the current
45
+ directory of size 500 characters.
46
+
47
+ If --name is provided without --path, the pad will be created in ~/number_station/pads/NAME/
48
+
49
+ Examples:
50
+ number_station pad create --name Shadow --numpads 1000 --length 1000
51
+ number_station pad create --name Shadow
52
+ number_station pad create --path ~/number_station/pads --numpads 10 --length 500
53
+ number_station pad create
54
+ CREATE_PAD_LONG_DESC
55
+ option :name, type: :string
56
+ option :length, type: :numeric
57
+ option :numpads, type: :numeric
58
+ option :path, type: :string
59
+ def create
60
+ ensure_config_loaded
61
+
62
+ # Determine the pad directory and agent name
63
+ pad_path = options[:path]
64
+ agent_name = options[:name]
65
+
66
+ if pad_path.nil? && agent_name
67
+ # If name is provided but no path, create agent-specific directory
68
+ pad_path = File.join(Dir.home, "number_station", "pads", agent_name)
69
+ FileUtils.mkdir_p(pad_path)
70
+ NumberStation.log.info "Created pad directory for agent: #{pad_path}"
71
+ end
72
+
73
+ # Pass options (make_otp will use defaults: 500 pads, 500 characters if nil)
74
+ # Also pass agent_name so it can be used in the filename
75
+ NumberStation.make_otp(pad_path, options[:length], options[:numpads], agent_name)
76
+ end
77
+
78
+ desc "stats [--path PATH]", "Show statistics about one-time pads"
79
+ long_desc <<-PAD_STATS_LONG_DESC
80
+ Lists all one-time pad files in the pads directory (default: ~/number_station/pads),
81
+ including pads in subdirectories. Pads are grouped by agent name (subdirectory name).
82
+
83
+ For each pad file, shows:
84
+ - Pad filename
85
+ - Maximum message length (in characters) that can be encrypted
86
+ - Number of unconsumed pads remaining
87
+
88
+ Optional parameters:
89
+ --path PATH Specify a custom directory path to examine pads from
90
+
91
+ Examples:
92
+ number_station pad stats
93
+ number_station pad stats --path ~/custom/pads
94
+ PAD_STATS_LONG_DESC
95
+ option :path, type: :string
96
+ def stats
97
+ ensure_config_loaded
98
+
99
+ pads_dir = options[:path] || File.join(Dir.home, "number_station", "pads")
100
+
101
+ unless Dir.exist?(pads_dir)
102
+ puts "Pads directory does not exist: #{pads_dir}"
103
+ return
104
+ end
105
+
106
+ # Find all pad files recursively in various formats:
107
+ # - Old format: one_time_pad_XXXXX.json (random number)
108
+ # - New format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json (date-based)
109
+ # - New format with counter: agentname-YYYY-MM-DD-001.json
110
+ pad_files = Dir.glob(File.join(pads_dir, "**", "*.json")).select do |file|
111
+ basename = File.basename(file)
112
+ # Match old format: one_time_pad_XXXXX.json
113
+ # Match new format: agentname-YYYY-MM-DD.json or one_time_pad-YYYY-MM-DD.json
114
+ # Match new format with counter: agentname-YYYY-MM-DD-001.json
115
+ basename.match?(/^(one_time_pad|[\w-]+)[_-]\d{4}-\d{2}-\d{2}(-\d{3})?\.json$/) ||
116
+ basename.match?(/^one_time_pad_\d+\.json$/) ||
117
+ basename.match?(/^[\w-]+_\d+\.json$/)
118
+ end
119
+
120
+ if pad_files.empty?
121
+ puts "No pad files found in #{pads_dir}"
122
+ return
123
+ end
124
+
125
+ # Group pads by their parent directory (agent name)
126
+ pads_by_agent = {}
127
+
128
+ pad_files.each do |file_path|
129
+ # Get relative path from pads_dir
130
+ relative_path = file_path.sub(/^#{Regexp.escape(pads_dir)}\/?/, '')
131
+ dir_parts = File.dirname(relative_path).split(File::SEPARATOR)
132
+
133
+ # Determine agent name: if in subdirectory, use subdirectory name; otherwise use "root"
134
+ agent_name = dir_parts.empty? || dir_parts == ['.'] ? "root" : dir_parts.first
135
+
136
+ pads_by_agent[agent_name] ||= []
137
+ pad_info = NumberStation.examine_pad_file(file_path)
138
+ pad_info[:file_path] = file_path
139
+ pad_info[:agent_name] = agent_name
140
+ pads_by_agent[agent_name] << pad_info
141
+ end
142
+
143
+ puts "\nOne-Time Pad Statistics"
144
+ puts "=" * 80
145
+ puts "Directory: #{pads_dir}"
146
+ puts "=" * 80
147
+ puts
148
+
149
+ # Sort agents: root first, then alphabetically
150
+ sorted_agents = pads_by_agent.keys.sort_by { |k| k == "root" ? "" : k }
151
+
152
+ total_pad_files = 0
153
+ total_unconsumed = 0
154
+ total_pads = 0
155
+
156
+ sorted_agents.each do |agent_name|
157
+ agent_pads = pads_by_agent[agent_name]
158
+ agent_unconsumed = agent_pads.inject(0) { |sum, info| sum + (info[:unconsumed_pads] || 0) }
159
+ agent_total_pads = agent_pads.inject(0) { |sum, info| sum + (info[:total_pads] || 0) }
160
+
161
+ # Display agent header
162
+ if agent_name == "root"
163
+ puts "Root Directory:"
164
+ else
165
+ puts "Agent: #{agent_name}"
166
+ end
167
+ puts "-" * 80
168
+
169
+ # Display each pad for this agent
170
+ agent_pads.each do |info|
171
+ if info[:error]
172
+ puts " ✗ #{info[:filename]}: ERROR - #{info[:error]}"
173
+ else
174
+ puts " Pad: #{info[:filename]}"
175
+ puts " ID: #{info[:pad_id]}"
176
+ puts " Maximum message length: #{info[:max_message_length]} characters"
177
+ puts " Total pads: #{info[:total_pads]}"
178
+ puts " Unconsumed: #{info[:unconsumed_pads]}"
179
+ puts " Consumed: #{info[:consumed_pads]}"
180
+ end
181
+ end
182
+
183
+ puts " Summary: #{agent_pads.size} pad file(s), #{agent_unconsumed} unconsumed pad(s) out of #{agent_total_pads} total"
184
+ puts
185
+
186
+ total_pad_files += agent_pads.size
187
+ total_unconsumed += agent_unconsumed
188
+ total_pads += agent_total_pads
189
+ end
190
+
191
+ puts "=" * 80
192
+ puts "Overall Summary: #{total_pad_files} pad file(s), #{total_unconsumed} unconsumed pad(s) out of #{total_pads} total"
193
+ puts "=" * 80
194
+ end
27
195
 
196
+ private
28
197
 
29
- # create_config
30
- desc "create_config [--path PATH]", "copy the sample config to current directory."
198
+ def ensure_config_loaded
199
+ NumberStation::ConfigReader.read_config unless NumberStation.data
200
+ end
201
+ end
202
+
203
+ class Agents < Thor
204
+ # Remove the built-in 'tree' command
205
+ def self.all_commands
206
+ super.reject { |k, v| k == 'tree' }
207
+ end
208
+
209
+ # Map hyphenated command names
210
+ map "update-handler" => :update_handler
211
+ map "list-all" => :list_all
212
+
213
+
214
+ desc "list", "List all active agents in a condensed format"
215
+ long_desc <<-LIST_AGENTS_LONG_DESC
216
+ Shows a condensed list of all active agents with their key information:
217
+ - Name
218
+ - Location
219
+ - Handler Codeword
220
+ - Start Date
221
+ LIST_AGENTS_LONG_DESC
222
+ def list
223
+ ensure_config_loaded
224
+
225
+ active_agents = NumberStation.active_agents
226
+
227
+ if active_agents.empty?
228
+ puts "No active agents found."
229
+ return
230
+ end
231
+
232
+ puts "\nActive Agents (#{active_agents.size})"
233
+ puts "=" * 100
234
+
235
+ # Print header
236
+ printf "%-20s %-25s %-25s %-15s\n", "Name", "Location", "Handler Codeword", "Start Date"
237
+ puts "-" * 100
238
+
239
+ # Print each agent
240
+ active_agents.each do |agent|
241
+ name = agent['name'] || 'N/A'
242
+ location = agent['location'] || '-'
243
+ handler = agent['handler_codeword'] || '-'
244
+ start_date = agent['start_date'] || '-'
245
+
246
+ printf "%-20s %-25s %-25s %-15s\n", name, location, handler, start_date
247
+ end
248
+
249
+ puts "=" * 100
250
+ end
251
+
252
+ desc "list-all", "List all active agents and inactive agents with end dates"
253
+ long_desc <<-LIST_ALL_AGENTS_LONG_DESC
254
+ Shows all active agents and all inactive agents that have an end_date in a condensed format with:
255
+ - Name
256
+ - Status (Active/Inactive)
257
+ - Location
258
+ - Handler Codeword
259
+ - Start Date
260
+ - End Date
261
+
262
+ Includes:
263
+ - All currently active agents (regardless of end_date)
264
+ - All inactive agents that have an end_date (were active and then deactivated)
265
+ LIST_ALL_AGENTS_LONG_DESC
266
+ def list_all
267
+ ensure_config_loaded
268
+
269
+ all_agents = NumberStation.agent_list
270
+
271
+ # Filter to: active agents OR inactive agents with end_date
272
+ filtered_agents = all_agents.select do |a|
273
+ a['active'] == true || (a['end_date'] && !a['end_date'].to_s.empty?)
274
+ end
275
+
276
+ if filtered_agents.empty?
277
+ puts "No active agents or agents with end dates found."
278
+ return
279
+ end
280
+
281
+ active_count = filtered_agents.count { |a| a['active'] }
282
+ inactive_count = filtered_agents.size - active_count
283
+
284
+ puts "\nAll Active Agents and Agents with End Dates (#{filtered_agents.size} total: #{active_count} active, #{inactive_count} inactive)"
285
+ puts "=" * 120
286
+
287
+ # Print header
288
+ printf "%-20s %-10s %-20s %-25s %-15s %-15s\n",
289
+ "Name", "Status", "Location", "Handler Codeword", "Start Date", "End Date"
290
+ puts "-" * 120
291
+
292
+ # Print each agent
293
+ filtered_agents.each do |agent|
294
+ name = agent['name'] || 'N/A'
295
+ status = agent['active'] ? 'Active' : 'Inactive'
296
+ location = agent['location'] || '-'
297
+ handler = agent['handler_codeword'] || '-'
298
+ start_date = agent['start_date'] || '-'
299
+ end_date = agent['end_date'] || '-'
300
+
301
+ printf "%-20s %-10s %-20s %-25s %-15s %-15s\n",
302
+ name, status, location, handler, start_date, end_date
303
+ end
304
+
305
+ puts "=" * 120
306
+ end
307
+
308
+ desc "stats", "Display statistics about active agents"
309
+ long_desc <<-AGENT_STATS_LONG_DESC
310
+ Shows statistics and details about all active agents, including:
311
+ - Total number of active agents
312
+ - Agent names and details (location, handler codeword, dates, etc.)
313
+ - Summary statistics
314
+ AGENT_STATS_LONG_DESC
315
+ def stats
316
+ ensure_config_loaded
317
+
318
+ all_agents = NumberStation.agent_list
319
+ active_agents = NumberStation.active_agents
320
+ inactive_count = all_agents.size - active_agents.size
321
+
322
+ puts "\nAgent Statistics"
323
+ puts "=" * 80
324
+ puts
325
+
326
+ # Summary statistics
327
+ puts "Summary:"
328
+ puts " Total agents: #{all_agents.size}"
329
+ puts " Active agents: #{active_agents.size}"
330
+ puts " Inactive agents: #{inactive_count}"
331
+ puts
332
+
333
+ if active_agents.empty?
334
+ puts "No active agents found."
335
+ puts "=" * 80
336
+ return
337
+ end
338
+
339
+ puts "Active Agents:"
340
+ puts "-" * 80
341
+ puts
342
+
343
+ active_agents.each_with_index do |agent, index|
344
+ puts "#{index + 1}. #{agent['name']}"
345
+ puts " Location: #{agent['location'] || 'Not specified'}"
346
+ puts " Handler Codeword: #{agent['handler_codeword'] || 'Not specified'}"
347
+ puts " Start Date: #{agent['start_date'] || 'Not specified'}"
348
+ puts " End Date: #{agent['end_date'] || 'Not specified'}"
349
+ puts " Status: Active"
350
+ puts
351
+ end
352
+
353
+ # Additional statistics
354
+ agents_with_location = active_agents.count { |a| a['location'] && !a['location'].to_s.empty? }
355
+ agents_with_codeword = active_agents.count { |a| a['handler_codeword'] && !a['handler_codeword'].to_s.empty? }
356
+ agents_with_start_date = active_agents.count { |a| a['start_date'] && !a['start_date'].to_s.empty? }
357
+ agents_with_end_date = active_agents.count { |a| a['end_date'] && !a['end_date'].to_s.empty? }
358
+
359
+ puts "-" * 80
360
+ puts "Additional Statistics:"
361
+ puts " Agents with location: #{agents_with_location}/#{active_agents.size}"
362
+ puts " Agents with handler codeword: #{agents_with_codeword}/#{active_agents.size}"
363
+ puts " Agents with start date: #{agents_with_start_date}/#{active_agents.size}"
364
+ puts " Agents with end date: #{agents_with_end_date}/#{active_agents.size}"
365
+ puts "=" * 80
366
+ end
367
+
368
+ desc "create NAME", "Create a new agent"
369
+ long_desc <<-CREATE_AGENT_LONG_DESC
370
+ Create a new agent with the specified name. Optionally set location and handler codeword.
371
+
372
+ Parameters:
373
+ NAME - The agent name (required)
374
+
375
+ Options:
376
+ --location LOCATION - Set the agent's location
377
+ --handler HANDLER - Set the handler codeword
378
+
379
+ Examples:
380
+ number_station agents create Shadow --location "Berlin" --handler "NIGHTFALL"
381
+ number_station agents create Ghost --location "Moscow"
382
+ number_station agents create Phantom
383
+ CREATE_AGENT_LONG_DESC
384
+ option :location, type: :string
385
+ option :handler, type: :string
386
+ def create(name = nil)
387
+ if name.nil? || name.empty?
388
+ help("create")
389
+ return
390
+ end
391
+
392
+ ensure_config_loaded
393
+
394
+ # Check if agent already exists
395
+ if NumberStation.find_agent_by_name(name)
396
+ raise Thor::Error, "Agent '#{name}' already exists"
397
+ end
398
+
399
+ # Create new agent
400
+ new_agent = {
401
+ "name" => name,
402
+ "location" => options[:location],
403
+ "handler_codeword" => options[:handler],
404
+ "start_date" => nil,
405
+ "end_date" => nil,
406
+ "active" => false
407
+ }
408
+
409
+ # Add to agent list
410
+ config_data = NumberStation.data.dup
411
+ config_data["agent_list"] ||= []
412
+ config_data["agent_list"] << new_agent
413
+
414
+ # Save config
415
+ NumberStation::ConfigReader.save_config(config_data)
416
+ puts "Created agent: #{name}"
417
+ puts " Location: #{options[:location] || 'Not specified'}"
418
+ puts " Handler Codeword: #{options[:handler] || 'Not specified'}"
419
+ end
420
+
421
+ desc "activate NAME", "Activate an agent"
422
+ long_desc <<-ACTIVATE_AGENT_LONG_DESC
423
+ Activate an agent by name. Sets active status to true and optionally sets start_date.
424
+
425
+ Parameters:
426
+ NAME - The agent name (required)
427
+
428
+ Options:
429
+ --start-date DATE - Set the start date (defaults to today if not specified)
430
+
431
+ Examples:
432
+ number_station agents activate Shadow
433
+ number_station agents activate Shadow --start-date "2024-01-15"
434
+ ACTIVATE_AGENT_LONG_DESC
435
+ option :start_date, type: :string
436
+ def activate(name = nil)
437
+ if name.nil? || name.empty?
438
+ help("activate")
439
+ return
440
+ end
441
+
442
+ ensure_config_loaded
443
+
444
+ agent = NumberStation.find_agent_by_name(name)
445
+ unless agent
446
+ raise Thor::Error, "Agent '#{name}' not found"
447
+ end
448
+
449
+ if agent["active"]
450
+ puts "Agent '#{name}' is already active"
451
+ return
452
+ end
453
+
454
+ # Update agent
455
+ config_data = NumberStation.data.dup
456
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
457
+
458
+ config_data["agent_list"][agent_index]["active"] = true
459
+ config_data["agent_list"][agent_index]["start_date"] = options[:start_date] || Date.today.to_s
460
+ config_data["agent_list"][agent_index]["end_date"] = nil # Reset end_date when reactivating
461
+
462
+ # Save config
463
+ NumberStation::ConfigReader.save_config(config_data)
464
+ puts "Activated agent: #{name}"
465
+ puts " Start Date: #{config_data["agent_list"][agent_index]["start_date"]}"
466
+ end
467
+
468
+ desc "deactivate NAME", "Deactivate an agent"
469
+ long_desc <<-DEACTIVATE_AGENT_LONG_DESC
470
+ Deactivate an agent by name. Sets active status to false and optionally sets end_date.
471
+
472
+ Parameters:
473
+ NAME - The agent name (required)
474
+
475
+ Options:
476
+ --end-date DATE - Set the end date (defaults to today if not specified)
477
+
478
+ Examples:
479
+ number_station agents deactivate Shadow
480
+ number_station agents deactivate Shadow --end-date "2024-12-31"
481
+ DEACTIVATE_AGENT_LONG_DESC
482
+ option :end_date, type: :string
483
+ def deactivate(name = nil)
484
+ if name.nil? || name.empty?
485
+ help("deactivate")
486
+ return
487
+ end
488
+
489
+ ensure_config_loaded
490
+
491
+ agent = NumberStation.find_agent_by_name(name)
492
+ unless agent
493
+ raise Thor::Error, "Agent '#{name}' not found"
494
+ end
495
+
496
+ unless agent["active"]
497
+ puts "Agent '#{name}' is already inactive"
498
+ return
499
+ end
500
+
501
+ # Update agent
502
+ config_data = NumberStation.data.dup
503
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
504
+
505
+ config_data["agent_list"][agent_index]["active"] = false
506
+ config_data["agent_list"][agent_index]["end_date"] = options[:end_date] || Date.today.to_s
507
+
508
+ # Save config
509
+ NumberStation::ConfigReader.save_config(config_data)
510
+ puts "Deactivated agent: #{name}"
511
+ puts " End Date: #{config_data["agent_list"][agent_index]["end_date"]}"
512
+ end
513
+
514
+ desc "update_handler NAME HANDLER", "Update the handler codeword for an agent"
515
+ long_desc <<-UPDATE_HANDLER_LONG_DESC
516
+ Update the handler codeword for an agent.
517
+
518
+ Parameters:
519
+ NAME - The agent name (required)
520
+ HANDLER - The new handler codeword (required)
521
+
522
+ Examples:
523
+ number_station agents update-handler Shadow DAWNBREAK
524
+ number_station agents update-handler Ghost NEWCODE123
525
+ UPDATE_HANDLER_LONG_DESC
526
+ def update_handler(name = nil, handler = nil)
527
+ if name.nil? || name.empty? || handler.nil? || handler.empty?
528
+ help("update_handler")
529
+ return
530
+ end
531
+
532
+ ensure_config_loaded
533
+
534
+ agent = NumberStation.find_agent_by_name(name)
535
+ unless agent
536
+ raise Thor::Error, "Agent '#{name}' not found"
537
+ end
538
+
539
+ # Update agent
540
+ config_data = NumberStation.data.dup
541
+ agent_index = config_data["agent_list"].index { |a| a["name"] == name }
542
+
543
+ old_handler = config_data["agent_list"][agent_index]["handler_codeword"]
544
+ config_data["agent_list"][agent_index]["handler_codeword"] = handler
545
+
546
+ # Save config
547
+ NumberStation::ConfigReader.save_config(config_data)
548
+ puts "Updated handler codeword for agent: #{name}"
549
+ puts " Old handler: #{old_handler || 'Not specified'}"
550
+ puts " New handler: #{handler}"
551
+ end
552
+
553
+ private
554
+
555
+ def ensure_config_loaded
556
+ NumberStation::ConfigReader.read_config unless NumberStation.data
557
+ end
558
+ end
559
+
560
+ class CLI < Thor
561
+ desc "create_config [--path PATH]", "create the config file conf.yaml"
31
562
  long_desc <<-CREATE_CONFIG_LONG_DESC
32
- `create_config` will copy the sample config from config/config.json to the current directory. If the
33
- optional parameter `--path PATH` is passed then this file is copied to the location specified by the
34
- PATH parameter.
35
- Sample Intro and Outro messages are also copied to this directory. If you wish to automatically append
36
- these messages, change the conf.json boolean.
563
+ `create_config` will copy the sample config from resources/conf.yaml to ~/number_station/conf.yaml.
564
+ If the optional parameter `--path PATH` is passed then the config file is created at PATH/conf.yaml.
37
565
  CREATE_CONFIG_LONG_DESC
38
- option :path, :type => :string
39
- def create_config()
40
- config_file_path = File.join(File.dirname(__FILE__), "../../resources/conf.json")
41
- intro_file_path = File.join(File.dirname(__FILE__), "../../resources/intro_message.txt")
42
- outro_file_path = File.join(File.dirname(__FILE__), "../../resources/outro_message.txt")
43
-
44
- options[:path] ? path = options[:path] : path = File.join(Dir.home, "/number_station")
45
-
46
- unless Dir.exist?(path) then FileUtils.mkdir(path) end
47
- unless File.file?(File.join(path, "conf.json")) then FileUtils.cp(config_file_path, File.join(path, "conf.json")) end
48
- unless File.file?(File.join(path, "intro_message.txt")) then FileUtils.cp(intro_file_path, File.join(path, "intro_message.txt")) end
49
- unless File.file?(File.join(path, "outro_message.txt")) then FileUtils.cp(outro_file_path, File.join(path, "outro_message.txt")) end
50
- NumberStation::ConfigReader.read_config()
566
+ option :path, type: :string
567
+ def create_config
568
+ ensure_config_loaded
569
+
570
+ target_path = options[:path] || File.join(Dir.home, "number_station")
571
+ FileUtils.mkdir_p(target_path)
572
+
573
+ target_file = File.join(target_path, "conf.yaml")
574
+
575
+ # Find the source config file - try multiple locations
576
+ source_file = find_config_template
577
+
578
+ unless File.exist?(target_file)
579
+ if source_file && File.exist?(source_file)
580
+ FileUtils.cp(source_file, target_file)
581
+ NumberStation.log.info "Created config file: #{target_file}"
582
+ else
583
+ # If template not found, create a default config
584
+ create_default_config(target_file)
585
+ NumberStation.log.info "Created default config file: #{target_file}"
586
+ end
587
+ else
588
+ NumberStation.log.info "Config file already exists: #{target_file}"
589
+ end
590
+
591
+ # Copy message template files (intro, outro, repeat) to target directory
592
+ copy_message_template_files(target_path)
593
+
594
+ NumberStation::ConfigReader.read_config
51
595
  NumberStation.log.debug "create_config completed"
52
596
  end
53
597
 
54
598
 
55
- # convert_to_phonetic
56
599
  desc "convert_to_phonetic [MESSAGE_PATH]", "Convert a message to phonetic output."
57
600
  long_desc <<-CONVERT_MESSAGE_LONG_DESC
58
601
  convert_message takes a parameter which should point to a text file containing a message.
59
602
  MESSAGE_PATH\n
60
603
  Optional parameters:\n
61
- --intro [INTRO_PATH] should be a text file containing intro message. Overrides value in conf.json\n
62
- --outro [OUTRO_PATH] should be a text file containing the outro message. Overrides value in conf.json\n
604
+ --intro [INTRO_PATH] should be a text file containing intro message. Overrides value in conf.yaml\n
605
+ --outro [OUTRO_PATH] should be a text file containing the outro message. Overrides value in conf.yaml\n
606
+ --repeat [REPEAT_PATH] should be a text file containing repeat message. Included after first message.\n
63
607
  --mp3 [MP3] output message as an mp3 file.
64
608
 
65
- Final message will be created from intro + message + outro
609
+ Final message will be created from: intro (as-is) + message (phonetic) + repeat (as-is) + message (phonetic again) + outro (as-is)
66
610
  CONVERT_MESSAGE_LONG_DESC
67
- option :intro, :type => :string
68
- option :outro, :type => :string
69
- option :mp3, :type => :string
611
+ option :intro, type: :string
612
+ option :outro, type: :string
613
+ option :repeat, type: :string
614
+ option :mp3, type: :string
70
615
  def convert_to_phonetic(message_path)
71
- NumberStation::ConfigReader.read_config()
72
-
73
- if options[:intro]
74
- intro_path = options[:intro]
75
- intro = NumberStation.to_phonetic(intro_path)
76
- elsif NumberStation.data["resources"]["intro"]["enabled"]
77
- intro_path = NumberStation.data["resources"]["intro"]["template"]
78
- intro = NumberStation.to_phonetic(intro_path)
79
- else
80
- intro_path = ""
81
- end
82
-
83
- if options[:outro]
84
- outro_path = options[:outro]
85
- outro = NumberStation.to_phonetic(outro_path)
86
- elsif NumberStation.data["resources"]["outro"]["enabled"]
87
- outro_path = NumberStation.data["resources"]["outro"]["template"]
88
- outro = NumberStation.to_phonetic(outro_path)
89
- else
90
- outro_path = ""
91
- outro = ""
92
- end
93
- NumberStation.log.debug "intro enabled: #{NumberStation.data["resources"]["intro"]["enabled"]} path: #{intro_path}"
94
- NumberStation.log.debug "message_path: #{message_path}"
95
- NumberStation.log.debug "outro enabled: #{NumberStation.data["resources"]["outro"]["enabled"]} path: #{outro_path}"
616
+ ensure_config_loaded
96
617
 
618
+ # Load intro, outro, and repeat as-is (not converted to phonetic)
619
+ intro = load_message_component(:intro, options[:intro])
620
+ outro = load_message_component(:outro, options[:outro])
621
+ repeat = load_message_component(:repeat, options[:repeat])
622
+
623
+ # Convert message to phonetic
97
624
  message = NumberStation.to_phonetic(message_path)
98
- output = intro + message + outro
625
+
626
+ # Build output: intro (as-is) + message (phonetic) + repeat (as-is) + message (phonetic again) + outro (as-is)
627
+ output_parts = []
628
+ output_parts << intro.strip if intro && !intro.strip.empty?
629
+ output_parts << message.strip if message && !message.strip.empty?
630
+ output_parts << repeat.strip if repeat && !repeat.strip.empty?
631
+ output_parts << message.strip if message && !message.strip.empty? # Repeat the message
632
+ output_parts << outro.strip if outro && !outro.strip.empty?
633
+
634
+ # Join with newlines between components for readability
635
+ # Strip each part to remove trailing newlines that might cause extra blank lines
636
+ output = output_parts.join("\n")
99
637
  NumberStation.log.info "output: #{output}"
100
638
 
639
+ # Generate output filename based on input filename
640
+ input_basename = File.basename(message_path, File.extname(message_path))
641
+ output_filename = "#{input_basename}_phonetic.txt"
642
+ output_path = File.join(File.dirname(message_path), output_filename)
643
+
644
+ # Save output to file
645
+ File.write(output_path, output)
646
+ NumberStation.log.info "Saved phonetic output to: #{output_path}"
647
+
101
648
  if options[:mp3]
102
- mp3_path = options[:mp3]
103
- NumberStation.log.debug "mp3_path: #{mp3_path}" if options[:mp3]
104
- NumberStation.log.debug "Generating mp3 output: #{mp3_path}"
105
- NumberStation.write_mp3(output, mp3_path)
649
+ NumberStation.log.debug "Generating mp3 output: #{options[:mp3]}"
650
+ NumberStation.write_mp3(output, options[:mp3])
106
651
  end
107
- return output
108
- end
109
652
 
653
+ output
654
+ end
110
655
 
111
- # make_one_time_pad
112
- desc "make_one_time_pad [--path PATH --numpads NUM --length LENGTH]", "Generate a one time pad of LENGTH containing NUM entries"
113
- long_desc <<-MAKE_ONE_TIME_PAD_LONG_DESC
114
- Generate a one time pad of LENGTH containing NUM entries
115
- Parameters:\n
116
- --path PATH\n
117
- --numpads NUM\n
118
- --length LENGTH
119
-
120
- If no parameters are passed it will generate 5 one time pads in the current
121
- directory of size 250 characters.
122
- MAKE_ONE_TIME_PAD_LONG_DESC
123
- option :length, :type => :numeric
124
- option :numpads, :type => :numeric
125
- option :path, :type => :string
126
- def make_one_time_pad()
127
- NumberStation::ConfigReader.read_config()
128
- NumberStation.log.debug "make_one_time_pad"
129
-
130
- length = options[:length]
131
- numpads = options[:numpads]
132
- path = options[:path]
133
- NumberStation.log.debug "length: #{length}" if options[:length]
134
- NumberStation.log.debug "numpads: #{numpads}" if options[:numpads]
135
- NumberStation.log.debug "path: #{path}" if options[:path]
136
-
137
- NumberStation.make_otp(path, length, numpads)
138
- end
139
-
140
-
141
- # encrypt message with a pad
142
- desc "encrypt_message [MESSAGE --numpad NUMPAD --padpath PADPATH]", "Encrypt a message using the key: NUMPAD in one time pad PADPATH"
143
- long_desc <<-ENCRYPT_MESSAGE_LONG_DESC
144
- Encrypt a message using key NUMPAD in one-time-pad PADPATH
145
- Parameters:\n
146
- MESSAGE
147
- --numpad NUMPAD\n
148
- --padpath PADPATH
656
+ desc "convert_to_espeak FILE", "Convert a phonetic message file to GLaDOS-style espeak XML"
657
+ long_desc <<-CONVERT_TO_ESPEAK_LONG_DESC
658
+ Convert a phonetic message file (typically generated by convert_to_phonetic) to GLaDOS-style espeak XML format.
659
+
660
+ Parameters:
661
+ FILE - Path to phonetic message file (e.g., Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.txt)
662
+
663
+ The output XML file will be saved with the same name as the input file, but with .xml extension.
664
+
665
+ Example:
666
+ number_station convert_to_espeak Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.txt
667
+ # Creates: Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
668
+ CONVERT_TO_ESPEAK_LONG_DESC
669
+ def convert_to_espeak(file)
670
+ ensure_config_loaded
671
+
672
+ unless File.exist?(file)
673
+ raise Thor::Error, "File not found: #{file}"
674
+ end
675
+
676
+ output_path = NumberStation.generate_glados_espeak(file)
677
+ puts "Generated GLaDOS espeak XML: #{output_path}"
678
+ end
149
679
 
150
- ENCRYPT_MESSAGE_LONG_DESC
151
- option :numpad, :type => :string
152
- option :padpath, :type => :string
153
- def encrypt_message(message)
154
- NumberStation::ConfigReader.read_config()
155
- NumberStation.log.debug "encrypt_message"
680
+ desc "convert_pad_to_asciidoc --padpath PADPATH", "Convert a pad file to AsciiDoc format"
681
+ long_desc <<-CONVERT_PAD_TO_ASCIIDOC_LONG_DESC
682
+ Convert a one-time pad file to AsciiDoc format with each pad as a chapter.
683
+
684
+ Parameters:
685
+ --padpath PADPATH - Path to pad file (e.g., ~/number_station/pads/Shadow/Shadow-2026-01-12.json)
686
+
687
+ Each pad in the file is converted to groups of 5 characters, separated by spaces.
688
+ Output saved as FILENAME.asciidoc in the same directory.
689
+
690
+ Example:
691
+ number_station convert_pad_to_asciidoc --padpath ~/number_station/pads/Shadow/Shadow-2026-01-12.json
692
+ CONVERT_PAD_TO_ASCIIDOC_LONG_DESC
693
+ option :padpath, type: :string, required: true
694
+ def convert_pad_to_asciidoc
695
+ ensure_config_loaded
696
+
697
+ pad_path = options[:padpath]
698
+ unless File.exist?(pad_path)
699
+ raise Thor::Error, "Pad file not found: #{pad_path}"
700
+ end
701
+
702
+ # Read pad file
703
+ pad_data = JSON.parse(File.read(pad_path))
704
+ pad_id = pad_data["id"]
705
+ pads_hash = pad_data["pads"]
706
+
707
+ if pads_hash.nil? || pads_hash.empty?
708
+ raise Thor::Error, "No pads found in file: #{pad_path}"
709
+ end
710
+
711
+ # Generate output filename: replace .json extension with .asciidoc
712
+ input_basename = File.basename(pad_path, File.extname(pad_path))
713
+ output_filename = "#{input_basename}.asciidoc"
714
+ output_path = File.join(File.dirname(pad_path), output_filename)
715
+
716
+ # Build AsciiDoc content
717
+ asciidoc_content = []
718
+ asciidoc_content << "= One-Time Pad: #{input_basename}"
719
+ asciidoc_content << ""
720
+
721
+ # Process each pad
722
+ pads_hash.sort_by { |k, v| k.to_i }.each do |pad_num, pad_info|
723
+ hex_key = pad_info["key"]
724
+
725
+ # Convert hex string to groups of 5 characters, separated by spaces
726
+ # Remove any existing formatting and group by 5
727
+ cleaned_hex = hex_key.gsub(/[\s\n\r]/, '')
728
+ grouped_hex = cleaned_hex.chars.each_slice(5).map(&:join).join(' ')
729
+
730
+ # Add chapter heading for this pad
731
+ asciidoc_content << "== Pad #{pad_num}"
732
+ asciidoc_content << ""
733
+ asciidoc_content << "[source]"
734
+ asciidoc_content << "----"
735
+ asciidoc_content << grouped_hex
736
+ asciidoc_content << "----"
737
+ asciidoc_content << ""
738
+
739
+ # Add page break after every 2nd pad (after pad 1, 3, 5, etc.)
740
+ if pad_num.to_i % 2 == 1
741
+ asciidoc_content << "<<<"
742
+ asciidoc_content << ""
743
+ end
744
+ end
745
+
746
+ # Write AsciiDoc file
747
+ File.write(output_path, asciidoc_content.join("\n"))
748
+ puts "Generated AsciiDoc file: #{output_path}"
749
+ end
156
750
 
157
- message_data = File.read(message)
158
- numpad = options[:numpad]
159
- padpath = options[:padpath]
751
+ desc "convert_to_pdf ASCIIDOC_FILE", "Convert an AsciiDoc file to PDF using asciidoctor-pdf"
752
+ long_desc <<-CONVERT_TO_PDF_LONG_DESC
753
+ Convert an AsciiDoc file (typically generated by convert_pad_to_asciidoc) to PDF format.
754
+
755
+ Parameters:
756
+ ASCIIDOC_FILE - Path to AsciiDoc file (e.g., Shadow-2026-01-12.asciidoc)
757
+
758
+ The command checks if asciidoctor-pdf is available before executing.
759
+ Output saved as FILENAME.pdf in the same directory.
760
+
761
+ Example:
762
+ number_station convert_to_pdf Shadow-2026-01-12.asciidoc
763
+ CONVERT_TO_PDF_LONG_DESC
764
+ def convert_to_pdf(asciidoc_file)
765
+ ensure_config_loaded
766
+
767
+ unless File.exist?(asciidoc_file)
768
+ raise Thor::Error, "File not found: #{asciidoc_file}"
769
+ end
770
+
771
+ # Check if asciidoctor-pdf is available
772
+ # Check if command exists first (most reliable)
773
+ unless NumberStation.command?('asciidoctor-pdf')
774
+ raise Thor::Error, "asciidoctor-pdf is not available. Please install it with: gem install asciidoctor-pdf"
775
+ end
776
+
777
+ # Generate output filename: replace .asciidoc extension with .pdf
778
+ input_basename = File.basename(asciidoc_file, File.extname(asciidoc_file))
779
+ output_filename = "#{input_basename}.pdf"
780
+ output_path = File.join(File.dirname(asciidoc_file), output_filename)
781
+
782
+ # Convert using asciidoctor-pdf command
783
+ cmd = "asciidoctor-pdf #{asciidoc_file} -o #{output_path}"
784
+ NumberStation.log.info "Running: #{cmd}"
785
+ system(cmd)
786
+
787
+ unless $?.success?
788
+ raise Thor::Error, "PDF conversion failed with exit code #{$?.exitstatus}"
789
+ end
790
+
791
+ puts "Generated PDF file: #{output_path}"
792
+ end
160
793
 
161
- NumberStation.log.debug "message: #{message}" if options[:message]
162
- NumberStation.log.debug "numpad: #{numpad}" if options[:numpad]
163
- NumberStation.log.debug "padpath: #{padpath}" if options[:padpath]
794
+ desc "espeak XML_FILE", "Use espeak to read an XML file"
795
+ long_desc <<-ESPEAK_LONG_DESC
796
+ Use the espeak utility to read an XML file with GLaDOS-style voice settings.
797
+
798
+ Parameters:
799
+ XML_FILE - Path to XML file (typically generated by convert_to_espeak)
800
+
801
+ The command checks if espeak is available on the system before executing.
802
+ Uses GLaDOS voice settings: -ven+f3 -m -p 60 -s 100 -g 4
803
+
804
+ Example:
805
+ number_station espeak Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
806
+ ESPEAK_LONG_DESC
807
+ def espeak(xml_file)
808
+ ensure_config_loaded
809
+
810
+ # Check if espeak is available
811
+ unless NumberStation.command?('espeak')
812
+ raise Thor::Error, "espeak utility is not available on this system. Please install espeak to use this command."
813
+ end
814
+
815
+ unless File.exist?(xml_file)
816
+ raise Thor::Error, "File not found: #{xml_file}"
817
+ end
818
+
819
+ # Call espeak with GLaDOS voice settings
820
+ cmd = "espeak -ven+f3 -m -p 60 -s 100 -g 4 -f #{xml_file}"
821
+ NumberStation.log.info "Running espeak: #{cmd}"
822
+ system(cmd)
823
+
824
+ unless $?.success?
825
+ raise Thor::Error, "espeak command failed with exit code #{$?.exitstatus}"
826
+ end
827
+ end
164
828
 
165
- enc_m = NumberStation.encrypt_message(message_data, padpath, numpad)
166
- NumberStation.log.debug "encrypted_message: #{enc_m}"
829
+ desc "convert_to_mp3 XML_FILE", "Convert an XML file to MP3 using espeak and ffmpeg"
830
+ long_desc <<-CONVERT_TO_MP3_LONG_DESC
831
+ Convert an XML file (typically generated by convert_to_espeak) to MP3 audio format.
832
+
833
+ Parameters:
834
+ XML_FILE - Path to XML file (e.g., Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml)
835
+
836
+ The command checks if both espeak and ffmpeg utilities are available before executing.
837
+ The output MP3 file will be saved with the same name as the input file, but with .mp3 extension.
838
+
839
+ Example:
840
+ number_station convert_to_mp3 Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.xml
841
+ # Creates: Abyss_Abyss-2026-01-12-001_pad2_encrypted_phonetic.mp3
842
+ CONVERT_TO_MP3_LONG_DESC
843
+ def convert_to_mp3(xml_file)
844
+ ensure_config_loaded
845
+
846
+ # Check if espeak and ffmpeg are available
847
+ unless NumberStation.command?('espeak')
848
+ raise Thor::Error, "espeak utility is not available on this system. Please install espeak to use this command."
849
+ end
850
+
851
+ unless NumberStation.command?('ffmpeg')
852
+ raise Thor::Error, "ffmpeg utility is not available on this system. Please install ffmpeg to use this command."
853
+ end
854
+
855
+ unless File.exist?(xml_file)
856
+ raise Thor::Error, "File not found: #{xml_file}"
857
+ end
858
+
859
+ # Generate output filename: replace .xml extension with .mp3
860
+ input_basename = File.basename(xml_file, File.extname(xml_file))
861
+ output_filename = "#{input_basename}.mp3"
862
+ output_path = File.join(File.dirname(xml_file), output_filename)
863
+
864
+ # Call espeak piped to ffmpeg
865
+ cmd = "espeak -ven+f3 -m -p 60 -s 100 -g 4 -f #{xml_file} --stdout | ffmpeg -i - -ar 44100 -ac 2 -ab 192k -f mp3 #{output_path}"
866
+ NumberStation.log.info "Running: #{cmd}"
867
+ system(cmd)
868
+
869
+ unless $?.success?
870
+ raise Thor::Error, "convert_to_mp3 command failed with exit code #{$?.exitstatus}"
871
+ end
872
+
873
+ puts "Generated MP3 file: #{output_path}"
167
874
  end
168
875
 
169
876
 
170
- # decrypt message with a pad
171
- desc "decrypt_message [MESSAGE --numpad NUMPAD --padpath PADPATH]", "Decrypt a message using the key: NUMPAD in one time pad PADPATH"
172
- long_desc <<-DECRYPT_MESSAGE_LONG_DESC
877
+ desc "encrypt [MESSAGE --file FILE --agent AGENT --numpad NUMPAD --padpath PADPATH]", "Encrypt a message using the key: NUMPAD in one time pad PADPATH"
878
+ long_desc <<-ENCRYPT_MESSAGE_LONG_DESC
173
879
  Encrypt a message using key NUMPAD in one-time-pad PADPATH
174
- Parameters:\n
175
- MESSAGE
176
- --numpad NUMPAD\n
177
- --padpath PADPATH
880
+
881
+ Parameters:
882
+ MESSAGE - Message string to encrypt (if --file is not provided)
883
+ --file FILE - Path to message file (alternative to passing message as argument)
884
+ --agent AGENT - Agent name. If provided, searches for oldest pad in ~/number_station/pads/AGENT/
885
+ --numpad NUMPAD - Pad number (optional, will try to auto-detect if not provided)
886
+ --padpath PADPATH - Path to pad file (optional, will try to auto-detect if not provided)
178
887
 
179
- DECRYPT_MESSAGE_LONG_DESC
180
- option :numpad, :type => :string
181
- option :padpath, :type => :string
182
- def decrypt_message(message)
183
- NumberStation::ConfigReader.read_config()
184
- NumberStation.log.debug "decrypt_message"
888
+ If --agent is provided, the system will search for the oldest pad with unconsumed pads
889
+ in the agent-specific directory (~/number_station/pads/AGENT/).
890
+
891
+ If --agent is not provided and --padpath/--numpad are not specified, the system will
892
+ search for the oldest pad with unconsumed pads in ~/number_station/pads/.
893
+
894
+ Examples:
895
+ number_station encrypt "Hello World" --agent Shadow
896
+ number_station encrypt --file message.txt --agent Shadow
897
+ number_station encrypt "Hello World" --agent Shadow --numpad 5
898
+ number_station encrypt --file message.txt --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 10
899
+ ENCRYPT_MESSAGE_LONG_DESC
900
+ option :file, type: :string
901
+ option :agent, type: :string
902
+ option :numpad, type: :string
903
+ option :padpath, type: :string
904
+ def encrypt(message = nil)
905
+ # Handle help request
906
+ if message == "help" || (message.nil? && options[:file].nil? && options[:agent].nil?)
907
+ help("encrypt")
908
+ return
909
+ end
910
+
911
+ ensure_config_loaded
912
+
913
+ # Validate agent if provided
914
+ if options[:agent]
915
+ agent = NumberStation.find_agent_by_name(options[:agent])
916
+ unless agent
917
+ raise Thor::Error, "Agent '#{options[:agent]}' not found"
918
+ end
919
+ unless agent["active"] == true
920
+ raise Thor::Error, "Agent '#{options[:agent]}' is inactive. Messages can only be encrypted for active agents."
921
+ end
922
+ end
923
+
924
+ # Determine message content: from --file option or from argument
925
+ if options[:file]
926
+ unless File.exist?(options[:file])
927
+ raise Thor::Error, "File not found: #{options[:file]}"
928
+ end
929
+ message_data = File.read(options[:file])
930
+ message_file_path = options[:file] # Store for filename generation
931
+ elsif message && !message.empty?
932
+ message_data = message
933
+ # If agent is specified, we'll save to file (even though input is string)
934
+ # Pass a flag to indicate we should save to file
935
+ message_file_path = options[:agent] ? "string_input" : nil
936
+ else
937
+ raise Thor::Error, "Either provide a message string as argument or use --file option"
938
+ end
939
+
940
+ # Auto-detect pad if not provided
941
+ if options[:padpath].nil? || options[:numpad].nil?
942
+ agent_name = options[:agent]
943
+ pad_info = NumberStation.find_next_available_pad(nil, message_data.size, true, agent_name)
944
+ pad_path = options[:padpath] || pad_info[:pad_path]
945
+ pad_num = options[:numpad] || pad_info[:pad_num]
946
+
947
+ agent_info = agent_name ? " for agent '#{agent_name}'" : ""
948
+ NumberStation.log.info "Using pad#{agent_info}: #{File.basename(pad_path)}, pad number: #{pad_num}"
949
+ else
950
+ pad_path = options[:padpath]
951
+ pad_num = options[:numpad]
952
+ end
953
+
954
+ encrypted = NumberStation.encrypt_message(message_data, pad_path, pad_num, message_file_path)
955
+ NumberStation.log.debug "encrypted_message: #{encrypted}"
956
+
957
+ # Output encrypted message to stdout if encrypting from string without agent
958
+ # (If agent is specified, we save to file instead)
959
+ unless message_file_path
960
+ puts encrypted
961
+ end
962
+ end
185
963
 
186
- message_data = File.read(message)
187
- numpad = options[:numpad]
188
- padpath = options[:padpath]
189
964
 
190
- NumberStation.log.debug "message: #{message}"
191
- NumberStation.log.debug "numpad: #{numpad}" if options[:numpad]
192
- NumberStation.log.debug "padpath: #{padpath}" if options[:padpath]
965
+ desc "decrypt [MESSAGE --file FILE --numpad NUMPAD --padpath PADPATH]", "Decrypt a message using the key: NUMPAD in one time pad PADPATH"
966
+ long_desc <<-DECRYPT_MESSAGE_LONG_DESC
967
+ Decrypt a message using key NUMPAD in one-time-pad PADPATH
968
+
969
+ Parameters:
970
+ MESSAGE - Encrypted message string to decrypt (if --file is not provided)
971
+ --file FILE - Path to encrypted message file (alternative to passing message as argument)
972
+ --numpad NUMPAD (optional, will try to auto-detect if not provided)
973
+ --padpath PADPATH (optional, will try to auto-detect if not provided)
193
974
 
194
- decrypt_m = NumberStation.decrypt_message(message_data, padpath, numpad)
195
- NumberStation.log.debug "decrypted_message: #{decrypt_m}"
975
+ If --numpad or --padpath are not specified, the system will attempt to find a matching pad.
976
+ Note: Auto-detection may be slower as it tries multiple pads.
977
+
978
+ Examples:
979
+ number_station decrypt "a1b2c3d4e5" --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 0
980
+ number_station decrypt --file encrypted.txt --padpath ~/number_station/pads/Shadow/Shadow-2024-01-15.json --numpad 0
981
+ number_station decrypt --file encrypted.txt
982
+ DECRYPT_MESSAGE_LONG_DESC
983
+ option :file, type: :string
984
+ option :numpad, type: :string
985
+ option :padpath, type: :string
986
+ def decrypt(message = nil)
987
+ # Handle help request
988
+ if message == "help" || (message.nil? && options[:file].nil?)
989
+ help("decrypt")
990
+ return
991
+ end
992
+
993
+ ensure_config_loaded
994
+
995
+ # Determine message content: from --file option or from argument
996
+ if options[:file]
997
+ unless File.exist?(options[:file])
998
+ raise Thor::Error, "File not found: #{options[:file]}"
999
+ end
1000
+ message_data = File.read(options[:file])
1001
+ message_file_path = options[:file] # Store for filename generation
1002
+ elsif message && !message.empty?
1003
+ message_data = message
1004
+ message_file_path = nil
1005
+ else
1006
+ raise Thor::Error, "Either provide an encrypted message string as argument or use --file option"
1007
+ end
1008
+
1009
+ # Auto-detect pad if not provided
1010
+ if options[:padpath].nil? || options[:numpad].nil?
1011
+ # For decryption, we don't require unconsumed pads (can decrypt with consumed pads)
1012
+ # Get cleaned message length for size check
1013
+ cleaned_message = message_data.gsub(/[\s\n\r]/, '')
1014
+ min_length = cleaned_message.length
1015
+
1016
+ begin
1017
+ pad_info = NumberStation.find_next_available_pad(nil, min_length, false)
1018
+ pad_path = options[:padpath] || pad_info[:pad_path]
1019
+ pad_num = options[:numpad] || pad_info[:pad_num]
1020
+
1021
+ NumberStation.log.info "Attempting decryption with pad: #{File.basename(pad_path)}, pad number: #{pad_num}"
1022
+ decrypted = NumberStation.decrypt_message(message_data, pad_path, pad_num, message_file_path)
1023
+ NumberStation.log.debug "decrypted_message: #{decrypted}"
1024
+
1025
+ # Output decrypted message to stdout if decrypting from string
1026
+ unless message_file_path
1027
+ puts decrypted
1028
+ end
1029
+ rescue ArgumentError, StandardError => e
1030
+ NumberStation.log.error "Failed to decrypt with auto-detected pad: #{e.message}"
1031
+ NumberStation.log.error "Please specify --padpath and --numpad explicitly for accurate decryption"
1032
+ raise
1033
+ end
1034
+ else
1035
+ pad_path = options[:padpath]
1036
+ pad_num = options[:numpad]
1037
+ decrypted = NumberStation.decrypt_message(message_data, pad_path, pad_num, message_file_path)
1038
+ NumberStation.log.debug "decrypted_message: #{decrypted}"
1039
+
1040
+ # Output decrypted message to stdout if decrypting from string
1041
+ unless message_file_path
1042
+ puts decrypted
1043
+ end
1044
+ end
196
1045
  end
197
1046
 
198
1047
 
199
- # version
1048
+ desc "agents SUBCOMMAND", "Manage and view agent information"
1049
+ long_desc <<-AGENTS_LONG_DESC
1050
+ Manage agents in your number station configuration.
1051
+
1052
+ Available subcommands:
1053
+ create NAME [--location LOCATION] [--handler HANDLER]
1054
+ Create a new agent
1055
+
1056
+ activate NAME [--start-date DATE]
1057
+ Activate an agent
1058
+
1059
+ deactivate NAME [--end-date DATE]
1060
+ Deactivate an agent
1061
+
1062
+ update-handler NAME HANDLER
1063
+ Update handler codeword for an agent
1064
+
1065
+ list
1066
+ Show condensed list of active agents
1067
+
1068
+ list-all
1069
+ Show all active agents and inactive agents with end dates
1070
+
1071
+ stats
1072
+ Show detailed statistics about active agents
1073
+
1074
+ Examples:
1075
+ number_station agents create Shadow --location "Berlin" --handler "NIGHTFALL"
1076
+ number_station agents activate Shadow
1077
+ number_station agents list
1078
+ number_station agents stats
1079
+
1080
+ For help on a specific subcommand:
1081
+ number_station agents help SUBCOMMAND
1082
+ AGENTS_LONG_DESC
1083
+ subcommand "agents", Agents
1084
+
1085
+ desc "pad SUBCOMMAND", "Manage one-time pads"
1086
+ long_desc <<-PAD_LONG_DESC
1087
+ Manage one-time pads for encryption/decryption.
1088
+
1089
+ Available subcommands:
1090
+ create [--name NAME --path PATH --numpads NUM --length LENGTH]
1091
+ Generate a new one-time pad file
1092
+ Use --name to create agent-specific pad directories
1093
+
1094
+ stats [--path PATH]
1095
+ Show statistics about one-time pads in a directory
1096
+
1097
+ Examples:
1098
+ number_station pad create --name Shadow --numpads 1000 --length 1000
1099
+ number_station pad create --name Shadow
1100
+ number_station pad create --path ~/number_station/pads --numpads 10 --length 500
1101
+ number_station pad create
1102
+ number_station pad stats
1103
+ number_station pad stats --path ~/custom/pads
1104
+
1105
+ For help on a specific subcommand:
1106
+ number_station pad help SUBCOMMAND
1107
+ PAD_LONG_DESC
1108
+ subcommand "pad", Pad
1109
+
200
1110
  desc "version", "Print the version of the Number Stations gem."
201
1111
  long_desc <<-VERSION_LONG_DESC
202
1112
  Prints the version of the Number Stations gem.
203
1113
  VERSION_LONG_DESC
204
- def version()
205
- NumberStation::ConfigReader.read_config()
206
- NumberStation.log.debug "Version: #{NumberStation::VERSION}"
1114
+ def version
1115
+ ensure_config_loaded
1116
+ puts NumberStation::VERSION
207
1117
  end
208
1118
 
209
- def self.exit_on_failure?()
1119
+ def self.exit_on_failure?
210
1120
  false
211
1121
  end
1122
+
1123
+ # Remove the built-in 'tree' command
1124
+ def self.all_commands
1125
+ super.reject { |k, v| k == 'tree' }
1126
+ end
1127
+
1128
+ private
1129
+
1130
+ def ensure_config_loaded
1131
+ NumberStation::ConfigReader.read_config unless NumberStation.data
1132
+ end
1133
+
1134
+ def load_message_component(type, override_path)
1135
+ resource_key = type.to_s
1136
+ config = NumberStation.data["resources"][resource_key]
1137
+
1138
+ content = if override_path
1139
+ # For intro, outro, and repeat, read as-is (not converted to phonetic)
1140
+ File.read(override_path)
1141
+ elsif config && config["enabled"]
1142
+ # For intro, outro, and repeat, read as-is (not converted to phonetic)
1143
+ File.read(config["template"])
1144
+ else
1145
+ ""
1146
+ end
1147
+
1148
+ # Strip trailing newlines to avoid extra blank lines when joining
1149
+ content.chomp
1150
+ end
1151
+
1152
+ def find_config_template
1153
+ # Try multiple possible locations
1154
+ possible_paths = [
1155
+ File.expand_path(File.join(File.dirname(__FILE__), "../../resources/conf.yaml")),
1156
+ File.expand_path(File.join(File.dirname(__FILE__), "../../../resources/conf.yaml"))
1157
+ ]
1158
+
1159
+ # Try gem datadir if available
1160
+ begin
1161
+ gem_path = File.join(Gem.datadir('number_station'), 'conf.yaml')
1162
+ possible_paths << gem_path if gem_path
1163
+ rescue
1164
+ # Gem.datadir not available, skip
1165
+ end
1166
+
1167
+ possible_paths.find { |path| path && File.exist?(path) }
1168
+ end
1169
+
1170
+ def find_resource_file(filename)
1171
+ # Try multiple possible locations for resource files
1172
+ possible_paths = [
1173
+ File.expand_path(File.join(File.dirname(__FILE__), "../../resources/#{filename}")),
1174
+ File.expand_path(File.join(File.dirname(__FILE__), "../../../resources/#{filename}"))
1175
+ ]
1176
+
1177
+ # Try gem datadir if available
1178
+ begin
1179
+ gem_path = File.join(Gem.datadir('number_station'), filename)
1180
+ possible_paths << gem_path if gem_path
1181
+ rescue
1182
+ # Gem.datadir not available, skip
1183
+ end
1184
+
1185
+ possible_paths.find { |path| path && File.exist?(path) }
1186
+ end
1187
+
1188
+ def copy_message_template_files(target_path)
1189
+ # Copy intro_message.txt, outro_message.txt, and repeat_message.txt
1190
+ template_files = ['intro_message.txt', 'outro_message.txt', 'repeat_message.txt']
1191
+
1192
+ template_files.each do |filename|
1193
+ source_file = find_resource_file(filename)
1194
+ target_file = File.join(target_path, filename)
1195
+
1196
+ unless File.exist?(target_file)
1197
+ if source_file && File.exist?(source_file)
1198
+ FileUtils.cp(source_file, target_file)
1199
+ NumberStation.log.info "Created template file: #{target_file}"
1200
+ else
1201
+ NumberStation.log.warn "Template file not found: #{filename}"
1202
+ end
1203
+ else
1204
+ NumberStation.log.debug "Template file already exists: #{target_file}"
1205
+ end
1206
+ end
1207
+ end
1208
+
1209
+ def create_default_config(target_file)
1210
+ default_config = <<~YAML
1211
+ logging:
1212
+ level: 0
1213
+
1214
+ server:
1215
+ host: "0.0.0.0"
1216
+ port: 8080
1217
+
1218
+ resources:
1219
+ intro:
1220
+ template: "intro_message.txt"
1221
+ enabled: true
1222
+ outro:
1223
+ template: "outro_message.txt"
1224
+ enabled: true
1225
+ repeat:
1226
+ template: "repeat_message.txt"
1227
+ enabled: false
1228
+
1229
+ agent_list:
1230
+ - name: Abyss
1231
+ location: null
1232
+ handler_codeword: null
1233
+ start_date: null
1234
+ end_date: null
1235
+ active: false
1236
+ - name: Ash
1237
+ location: null
1238
+ handler_codeword: null
1239
+ start_date: null
1240
+ end_date: null
1241
+ active: false
1242
+ - name: Blade
1243
+ location: null
1244
+ handler_codeword: null
1245
+ start_date: null
1246
+ end_date: null
1247
+ active: false
1248
+ - name: Blitz
1249
+ location: null
1250
+ handler_codeword: null
1251
+ start_date: null
1252
+ end_date: null
1253
+ active: false
1254
+ - name: Cipher
1255
+ location: null
1256
+ handler_codeword: null
1257
+ start_date: null
1258
+ end_date: null
1259
+ active: false
1260
+ - name: Cobra
1261
+ location: null
1262
+ handler_codeword: null
1263
+ start_date: null
1264
+ end_date: null
1265
+ active: false
1266
+ - name: Dagger
1267
+ location: null
1268
+ handler_codeword: null
1269
+ start_date: null
1270
+ end_date: null
1271
+ active: false
1272
+ - name: Drift
1273
+ location: null
1274
+ handler_codeword: null
1275
+ start_date: null
1276
+ end_date: null
1277
+ active: false
1278
+ - name: Dusk
1279
+ location: null
1280
+ handler_codeword: null
1281
+ start_date: null
1282
+ end_date: null
1283
+ active: false
1284
+ - name: Eclipse
1285
+ location: null
1286
+ handler_codeword: null
1287
+ start_date: null
1288
+ end_date: null
1289
+ active: false
1290
+ - name: Enigma
1291
+ location: null
1292
+ handler_codeword: null
1293
+ start_date: null
1294
+ end_date: null
1295
+ active: false
1296
+ - name: Fang
1297
+ location: null
1298
+ handler_codeword: null
1299
+ start_date: null
1300
+ end_date: null
1301
+ active: false
1302
+ - name: Flux
1303
+ location: null
1304
+ handler_codeword: null
1305
+ start_date: null
1306
+ end_date: null
1307
+ active: false
1308
+ - name: Frost
1309
+ location: null
1310
+ handler_codeword: null
1311
+ start_date: null
1312
+ end_date: null
1313
+ active: false
1314
+ - name: Frostbite
1315
+ location: null
1316
+ handler_codeword: null
1317
+ start_date: null
1318
+ end_date: null
1319
+ active: false
1320
+ - name: Ghost
1321
+ location: null
1322
+ handler_codeword: null
1323
+ start_date: null
1324
+ end_date: null
1325
+ active: false
1326
+ - name: Grim
1327
+ location: null
1328
+ handler_codeword: null
1329
+ start_date: null
1330
+ end_date: null
1331
+ active: false
1332
+ - name: Grimlock
1333
+ location: null
1334
+ handler_codeword: null
1335
+ start_date: null
1336
+ end_date: null
1337
+ active: false
1338
+ - name: Havoc
1339
+ location: null
1340
+ handler_codeword: null
1341
+ start_date: null
1342
+ end_date: null
1343
+ active: false
1344
+ - name: Helix
1345
+ location: null
1346
+ handler_codeword: null
1347
+ start_date: null
1348
+ end_date: null
1349
+ active: false
1350
+ - name: Hunter
1351
+ location: null
1352
+ handler_codeword: null
1353
+ start_date: null
1354
+ end_date: null
1355
+ active: false
1356
+ - name: Iron
1357
+ location: null
1358
+ handler_codeword: null
1359
+ start_date: null
1360
+ end_date: null
1361
+ active: false
1362
+ - name: Ironclad
1363
+ location: null
1364
+ handler_codeword: null
1365
+ start_date: null
1366
+ end_date: null
1367
+ active: false
1368
+ - name: Jinx
1369
+ location: null
1370
+ handler_codeword: null
1371
+ start_date: null
1372
+ end_date: null
1373
+ active: false
1374
+ - name: Kraken
1375
+ location: null
1376
+ handler_codeword: null
1377
+ start_date: null
1378
+ end_date: null
1379
+ active: false
1380
+ - name: Nexus
1381
+ location: null
1382
+ handler_codeword: null
1383
+ start_date: null
1384
+ end_date: null
1385
+ active: false
1386
+ - name: Nightfall
1387
+ location: null
1388
+ handler_codeword: null
1389
+ start_date: null
1390
+ end_date: null
1391
+ active: false
1392
+ - name: Nightshade
1393
+ location: null
1394
+ handler_codeword: null
1395
+ start_date: null
1396
+ end_date: null
1397
+ active: false
1398
+ - name: Nova
1399
+ location: null
1400
+ handler_codeword: null
1401
+ start_date: null
1402
+ end_date: null
1403
+ active: false
1404
+ - name: Nyx
1405
+ location: null
1406
+ handler_codeword: null
1407
+ start_date: null
1408
+ end_date: null
1409
+ active: false
1410
+ - name: Obsidian
1411
+ location: null
1412
+ handler_codeword: null
1413
+ start_date: null
1414
+ end_date: null
1415
+ active: false
1416
+ - name: Onyx
1417
+ location: null
1418
+ handler_codeword: null
1419
+ start_date: null
1420
+ end_date: null
1421
+ active: false
1422
+ - name: Phantom
1423
+ location: null
1424
+ handler_codeword: null
1425
+ start_date: null
1426
+ end_date: null
1427
+ active: false
1428
+ - name: Pulse
1429
+ location: null
1430
+ handler_codeword: null
1431
+ start_date: null
1432
+ end_date: null
1433
+ active: false
1434
+ - name: Raptor
1435
+ location: null
1436
+ handler_codeword: null
1437
+ start_date: null
1438
+ end_date: null
1439
+ active: false
1440
+ - name: Raven
1441
+ location: null
1442
+ handler_codeword: null
1443
+ start_date: null
1444
+ end_date: null
1445
+ active: false
1446
+ - name: Razor
1447
+ location: null
1448
+ handler_codeword: null
1449
+ start_date: null
1450
+ end_date: null
1451
+ active: false
1452
+ - name: Reaper
1453
+ location: null
1454
+ handler_codeword: null
1455
+ start_date: null
1456
+ end_date: null
1457
+ active: false
1458
+ - name: Revenant
1459
+ location: null
1460
+ handler_codeword: null
1461
+ start_date: null
1462
+ end_date: null
1463
+ active: false
1464
+ - name: Riven
1465
+ location: null
1466
+ handler_codeword: null
1467
+ start_date: null
1468
+ end_date: null
1469
+ active: false
1470
+ - name: Rogue
1471
+ location: null
1472
+ handler_codeword: null
1473
+ start_date: null
1474
+ end_date: null
1475
+ active: false
1476
+ - name: Saber
1477
+ location: null
1478
+ handler_codeword: null
1479
+ start_date: null
1480
+ end_date: null
1481
+ active: false
1482
+ - name: Scorch
1483
+ location: null
1484
+ handler_codeword: null
1485
+ start_date: null
1486
+ end_date: null
1487
+ active: false
1488
+ - name: Scorpion
1489
+ location: null
1490
+ handler_codeword: null
1491
+ start_date: null
1492
+ end_date: null
1493
+ active: false
1494
+ - name: Shade
1495
+ location: null
1496
+ handler_codeword: null
1497
+ start_date: null
1498
+ end_date: null
1499
+ active: false
1500
+ - name: Shadow
1501
+ location: null
1502
+ handler_codeword: null
1503
+ start_date: null
1504
+ end_date: null
1505
+ active: false
1506
+ - name: Shard
1507
+ location: null
1508
+ handler_codeword: null
1509
+ start_date: null
1510
+ end_date: null
1511
+ active: false
1512
+ - name: Slate
1513
+ location: null
1514
+ handler_codeword: null
1515
+ start_date: null
1516
+ end_date: null
1517
+ active: false
1518
+ - name: Specter
1519
+ location: null
1520
+ handler_codeword: null
1521
+ start_date: null
1522
+ end_date: null
1523
+ active: false
1524
+ - name: Storm
1525
+ location: null
1526
+ handler_codeword: null
1527
+ start_date: null
1528
+ end_date: null
1529
+ active: false
1530
+ - name: Striker
1531
+ location: null
1532
+ handler_codeword: null
1533
+ start_date: null
1534
+ end_date: null
1535
+ active: false
1536
+ - name: Surge
1537
+ location: null
1538
+ handler_codeword: null
1539
+ start_date: null
1540
+ end_date: null
1541
+ active: false
1542
+ - name: Talon
1543
+ location: null
1544
+ handler_codeword: null
1545
+ start_date: null
1546
+ end_date: null
1547
+ active: false
1548
+ - name: Tempest
1549
+ location: null
1550
+ handler_codeword: null
1551
+ start_date: null
1552
+ end_date: null
1553
+ active: false
1554
+ - name: Thorn
1555
+ location: null
1556
+ handler_codeword: null
1557
+ start_date: null
1558
+ end_date: null
1559
+ active: false
1560
+ - name: Titan
1561
+ location: null
1562
+ handler_codeword: null
1563
+ start_date: null
1564
+ end_date: null
1565
+ active: false
1566
+ - name: Vantage
1567
+ location: null
1568
+ handler_codeword: null
1569
+ start_date: null
1570
+ end_date: null
1571
+ active: false
1572
+ - name: Venom
1573
+ location: null
1574
+ handler_codeword: null
1575
+ start_date: null
1576
+ end_date: null
1577
+ active: false
1578
+ - name: Vex
1579
+ location: null
1580
+ handler_codeword: null
1581
+ start_date: null
1582
+ end_date: null
1583
+ active: false
1584
+ - name: Viper
1585
+ location: null
1586
+ handler_codeword: null
1587
+ start_date: null
1588
+ end_date: null
1589
+ active: false
1590
+ - name: Void
1591
+ location: null
1592
+ handler_codeword: null
1593
+ start_date: null
1594
+ end_date: null
1595
+ active: false
1596
+ - name: Voidwalker
1597
+ location: null
1598
+ handler_codeword: null
1599
+ start_date: null
1600
+ end_date: null
1601
+ active: false
1602
+ - name: Vortex
1603
+ location: null
1604
+ handler_codeword: null
1605
+ start_date: null
1606
+ end_date: null
1607
+ active: false
1608
+ - name: Wraith
1609
+ location: null
1610
+ handler_codeword: null
1611
+ start_date: null
1612
+ end_date: null
1613
+ active: false
1614
+ - name: Zephyr
1615
+ location: null
1616
+ handler_codeword: null
1617
+ start_date: null
1618
+ end_date: null
1619
+ active: false
1620
+ - name: Zero
1621
+ location: null
1622
+ handler_codeword: null
1623
+ start_date: null
1624
+ end_date: null
1625
+ active: false
1626
+ YAML
1627
+
1628
+ File.write(target_file, default_config)
1629
+ end
212
1630
  end
213
1631
  end