ace-b36ts 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ace-defaults/b36ts/config.yml +13 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-b36ts.yml +19 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-b36ts.yml +19 -0
- data/CHANGELOG.md +283 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +17 -0
- data/exe/ace-b36ts +14 -0
- data/handbook/agents/b36ts.ag.md +93 -0
- data/handbook/skills/as-b36ts/SKILL.md +20 -0
- data/handbook/workflow-instructions/b36ts.wf.md +127 -0
- data/lib/ace/b36ts/atoms/compact_id_encoder.rb +656 -0
- data/lib/ace/b36ts/atoms/format_codecs.rb +661 -0
- data/lib/ace/b36ts/atoms/format_specs.rb +178 -0
- data/lib/ace/b36ts/atoms/formats.rb +110 -0
- data/lib/ace/b36ts/cli/commands/config.rb +29 -0
- data/lib/ace/b36ts/cli/commands/decode.rb +47 -0
- data/lib/ace/b36ts/cli/commands/encode.rb +52 -0
- data/lib/ace/b36ts/cli.rb +71 -0
- data/lib/ace/b36ts/commands/config_command.rb +52 -0
- data/lib/ace/b36ts/commands/decode_command.rb +104 -0
- data/lib/ace/b36ts/commands/encode_command.rb +179 -0
- data/lib/ace/b36ts/molecules/config_resolver.rb +166 -0
- data/lib/ace/b36ts/version.rb +11 -0
- data/lib/ace/b36ts.rb +203 -0
- metadata +157 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module B36ts
|
|
5
|
+
module Atoms
|
|
6
|
+
# Format specifications for granular timestamp encoding.
|
|
7
|
+
#
|
|
8
|
+
# Defines 7 format types with varying precision and length:
|
|
9
|
+
# - 2sec (6 chars, ~1.85s precision) - default
|
|
10
|
+
# - month (2 chars, month precision)
|
|
11
|
+
# - week (3 chars, week precision)
|
|
12
|
+
# - day (3 chars, day precision)
|
|
13
|
+
# - 40min (4 chars, 40-minute block precision)
|
|
14
|
+
# - 50ms (7 chars, ~50ms precision)
|
|
15
|
+
# - ms (8 chars, ~1.4ms precision)
|
|
16
|
+
#
|
|
17
|
+
# Day/week disambiguation (3-char formats):
|
|
18
|
+
# - Day format: 3rd character in 0-30 range
|
|
19
|
+
# - Week format: 3rd character in 31-35 range
|
|
20
|
+
#
|
|
21
|
+
# @example Access format specifications
|
|
22
|
+
# FormatSpecs::FORMATS[:"2sec"] # => FormatSpec for 6-char IDs
|
|
23
|
+
# FormatSpecs::FORMATS[:month] # => FormatSpec for 2-char IDs
|
|
24
|
+
#
|
|
25
|
+
module FormatSpecs
|
|
26
|
+
# Immutable value object defining a timestamp format specification
|
|
27
|
+
FormatSpec = Data.define(:name, :length, :precision_desc, :pattern)
|
|
28
|
+
|
|
29
|
+
# All supported format specifications
|
|
30
|
+
FORMATS = {
|
|
31
|
+
"2sec": FormatSpec.new(
|
|
32
|
+
name: :"2sec",
|
|
33
|
+
length: 6,
|
|
34
|
+
precision_desc: "~1.85s",
|
|
35
|
+
pattern: /\A[0-9a-z]{6}\z/i
|
|
36
|
+
),
|
|
37
|
+
month: FormatSpec.new(
|
|
38
|
+
name: :month,
|
|
39
|
+
length: 2,
|
|
40
|
+
precision_desc: "month",
|
|
41
|
+
pattern: /\A[0-9a-z]{2}\z/i
|
|
42
|
+
),
|
|
43
|
+
week: FormatSpec.new(
|
|
44
|
+
name: :week,
|
|
45
|
+
length: 3,
|
|
46
|
+
precision_desc: "week",
|
|
47
|
+
pattern: /\A[0-9a-z]{3}\z/i
|
|
48
|
+
),
|
|
49
|
+
day: FormatSpec.new(
|
|
50
|
+
name: :day,
|
|
51
|
+
length: 3,
|
|
52
|
+
precision_desc: "day",
|
|
53
|
+
pattern: /\A[0-9a-z]{3}\z/i
|
|
54
|
+
),
|
|
55
|
+
"40min": FormatSpec.new(
|
|
56
|
+
name: :"40min",
|
|
57
|
+
length: 4,
|
|
58
|
+
precision_desc: "40min",
|
|
59
|
+
pattern: /\A[0-9a-z]{4}\z/i
|
|
60
|
+
),
|
|
61
|
+
"50ms": FormatSpec.new(
|
|
62
|
+
name: :"50ms",
|
|
63
|
+
length: 7,
|
|
64
|
+
precision_desc: "~50ms",
|
|
65
|
+
pattern: /\A[0-9a-z]{7}\z/i
|
|
66
|
+
),
|
|
67
|
+
ms: FormatSpec.new(
|
|
68
|
+
name: :ms,
|
|
69
|
+
length: 8,
|
|
70
|
+
precision_desc: "~1.4ms",
|
|
71
|
+
pattern: /\A[0-9a-z]{8}\z/i
|
|
72
|
+
)
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
# Day/week disambiguation for 3-char formats
|
|
76
|
+
# Day format: 3rd char (day value) is 0-30
|
|
77
|
+
# Week format: 3rd char (week value) is 31-35
|
|
78
|
+
DAY_FORMAT_MAX = 30
|
|
79
|
+
WEEK_FORMAT_MIN = 31
|
|
80
|
+
WEEK_FORMAT_MAX = 35
|
|
81
|
+
SPLIT_LEVELS = %i[month week day block].freeze
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# Get format specification by name
|
|
85
|
+
#
|
|
86
|
+
# @param name [Symbol] Format name (:"2sec", :month, :week, :day, :"40min", :"50ms", :ms)
|
|
87
|
+
# @return [FormatSpec, nil] Format specification or nil if not found
|
|
88
|
+
def get(name)
|
|
89
|
+
FORMATS[name]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if format name is valid
|
|
93
|
+
#
|
|
94
|
+
# @param name [Symbol] Format name to check
|
|
95
|
+
# @return [Boolean] True if format is supported
|
|
96
|
+
def valid_format?(name)
|
|
97
|
+
FORMATS.key?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get all format names
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<Symbol>] List of supported format names
|
|
103
|
+
def all_formats
|
|
104
|
+
FORMATS.keys
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get all supported lengths
|
|
108
|
+
#
|
|
109
|
+
# @return [Array<Integer>] List of supported ID lengths
|
|
110
|
+
def all_lengths
|
|
111
|
+
FORMATS.values.map(&:length).uniq.sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Validate split level ordering and hierarchy
|
|
115
|
+
#
|
|
116
|
+
# @param levels [Array<Symbol>] Split levels to validate
|
|
117
|
+
# @return [Boolean] True if split levels are valid
|
|
118
|
+
def valid_split_levels?(levels)
|
|
119
|
+
return false unless levels.is_a?(Array)
|
|
120
|
+
return false if levels.empty?
|
|
121
|
+
return false unless levels.all? { |level| SPLIT_LEVELS.include?(level) }
|
|
122
|
+
return false unless levels.uniq.length == levels.length
|
|
123
|
+
return false unless levels.first == :month
|
|
124
|
+
|
|
125
|
+
indices = levels.map { |level| SPLIT_LEVELS.index(level) }
|
|
126
|
+
return false unless indices == indices.sort
|
|
127
|
+
return false if levels.include?(:block) && !levels.include?(:day)
|
|
128
|
+
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Detect format from ID string
|
|
133
|
+
# For 3-char IDs, uses the 3rd character value to distinguish day vs week
|
|
134
|
+
#
|
|
135
|
+
# @param encoded_id [String] The encoded ID string
|
|
136
|
+
# @param alphabet [String] Base36 alphabet
|
|
137
|
+
# @return [Symbol, nil] Detected format name or nil if invalid
|
|
138
|
+
def detect_from_id(encoded_id, alphabet: CompactIdEncoder::DEFAULT_ALPHABET)
|
|
139
|
+
return nil if encoded_id.nil? || encoded_id.empty?
|
|
140
|
+
|
|
141
|
+
# Validate all characters are in the alphabet
|
|
142
|
+
# Use Set for faster character validation (O(1) vs O(n))
|
|
143
|
+
alphabet_set = (alphabet == CompactIdEncoder::DEFAULT_ALPHABET) ? CompactIdEncoder::DEFAULT_ALPHABET_SET : alphabet.chars.to_set
|
|
144
|
+
return nil unless encoded_id.downcase.chars.all? { |c| alphabet_set.include?(c) }
|
|
145
|
+
|
|
146
|
+
length = encoded_id.length
|
|
147
|
+
|
|
148
|
+
case length
|
|
149
|
+
when 2
|
|
150
|
+
:month
|
|
151
|
+
when 3
|
|
152
|
+
# Disambiguate day vs week by 3rd character value
|
|
153
|
+
third_char_value = alphabet.index(encoded_id[2].downcase)
|
|
154
|
+
return nil if third_char_value.nil?
|
|
155
|
+
|
|
156
|
+
if third_char_value <= DAY_FORMAT_MAX
|
|
157
|
+
:day
|
|
158
|
+
elsif third_char_value <= WEEK_FORMAT_MAX
|
|
159
|
+
:week
|
|
160
|
+
end
|
|
161
|
+
# Note: With base36 (values 0-35), day covers 0-30 and week covers 31-35,
|
|
162
|
+
# so all valid values are handled. This branch implicitly returns nil
|
|
163
|
+
# only if alphabet validation fails (which is caught earlier).
|
|
164
|
+
when 4
|
|
165
|
+
:"40min"
|
|
166
|
+
when 6
|
|
167
|
+
:"2sec"
|
|
168
|
+
when 7
|
|
169
|
+
:"50ms"
|
|
170
|
+
when 8
|
|
171
|
+
:ms
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "format_specs"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module B36ts
|
|
7
|
+
module Atoms
|
|
8
|
+
# Detects and validates timestamp format types.
|
|
9
|
+
#
|
|
10
|
+
# Supports multiple compact ID formats:
|
|
11
|
+
# - :"2sec" - 6-character Base36 compact ID (e.g., "i50jj3")
|
|
12
|
+
# - :month - 2-character Base36 month ID (e.g., "i5")
|
|
13
|
+
# - :week - 3-character Base36 week ID (e.g., "i5v")
|
|
14
|
+
# - :day - 3-character Base36 day ID (e.g., "i50")
|
|
15
|
+
# - :"40min" - 4-character Base36 40min block ID (e.g., "i50j")
|
|
16
|
+
# - :"50ms" - 7-character Base36 high-precision ID (e.g., "i50jj3z")
|
|
17
|
+
# - :ms - 8-character Base36 high-precision ID (e.g., "i50jj3zz")
|
|
18
|
+
# - :timestamp - 14-character timestamp format (e.g., "20250101-120000")
|
|
19
|
+
#
|
|
20
|
+
# @example Detect format
|
|
21
|
+
# Formats.detect("i50jj3") # => :"2sec"
|
|
22
|
+
# Formats.detect("i5") # => :month
|
|
23
|
+
# Formats.detect("i5v") # => :week (3rd char 31-35)
|
|
24
|
+
# Formats.detect("i50") # => :day (3rd char 0-30)
|
|
25
|
+
# Formats.detect("i50j") # => :"40min"
|
|
26
|
+
# Formats.detect("i50jj3z") # => :"50ms"
|
|
27
|
+
# Formats.detect("i50jj3zz") # => :ms
|
|
28
|
+
# Formats.detect("20250101-120000") # => :timestamp
|
|
29
|
+
# Formats.detect("invalid") # => nil
|
|
30
|
+
#
|
|
31
|
+
module Formats
|
|
32
|
+
# Regex patterns for format detection
|
|
33
|
+
MONTH_PATTERN = /\A[0-9a-z]{2}\z/i
|
|
34
|
+
DAY_WEEK_PATTERN = /\A[0-9a-z]{3}\z/i # Disambiguate by 3rd char value
|
|
35
|
+
HOUR_PATTERN = /\A[0-9a-z]{4}\z/i
|
|
36
|
+
COMPACT_PATTERN = /\A[0-9a-z]{6}\z/i
|
|
37
|
+
HIGH_7_PATTERN = /\A[0-9a-z]{7}\z/i
|
|
38
|
+
HIGH_8_PATTERN = /\A[0-9a-z]{8}\z/i
|
|
39
|
+
TIMESTAMP_PATTERN = /\A\d{8}-\d{6}\z/
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Detect the format type of a timestamp string
|
|
43
|
+
#
|
|
44
|
+
# For 3-character IDs, uses the 3rd character value to distinguish day vs week:
|
|
45
|
+
# - Day format: 3rd char in 0-30 range
|
|
46
|
+
# - Week format: 3rd char in 31-35 range
|
|
47
|
+
#
|
|
48
|
+
# @param value [String] The timestamp string to analyze
|
|
49
|
+
# @return [Symbol, nil] :"2sec", :month, :week, :day, :"40min", :"50ms", :ms, :timestamp, or nil if unrecognized
|
|
50
|
+
def detect(value)
|
|
51
|
+
return nil unless value.is_a?(String)
|
|
52
|
+
|
|
53
|
+
# For compact ID formats, delegate to FormatSpecs for proper detection
|
|
54
|
+
# including day/week disambiguation
|
|
55
|
+
if FormatSpecs::FORMATS.values.any? { |spec| spec.pattern.match?(value) }
|
|
56
|
+
FormatSpecs.detect_from_id(value)
|
|
57
|
+
elsif TIMESTAMP_PATTERN.match?(value)
|
|
58
|
+
:timestamp
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if value is a valid compact ID format
|
|
63
|
+
#
|
|
64
|
+
# @param value [String] The string to check
|
|
65
|
+
# @return [Boolean] true if valid compact format
|
|
66
|
+
def compact?(value)
|
|
67
|
+
detect(value) == :"2sec"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if value is a valid timestamp format
|
|
71
|
+
#
|
|
72
|
+
# @param value [String] The string to check
|
|
73
|
+
# @return [Boolean] true if valid timestamp format
|
|
74
|
+
def timestamp?(value)
|
|
75
|
+
detect(value) == :timestamp
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parse a timestamp format string to Time
|
|
79
|
+
#
|
|
80
|
+
# @param value [String] Timestamp in "YYYYMMDD-HHMMSS" format
|
|
81
|
+
# @return [Time] Parsed time in UTC
|
|
82
|
+
# @raise [ArgumentError] If format is invalid
|
|
83
|
+
def parse_timestamp(value)
|
|
84
|
+
unless timestamp?(value)
|
|
85
|
+
raise ArgumentError, "Invalid timestamp format: #{value} (expected YYYYMMDD-HHMMSS)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
year = value[0..3].to_i
|
|
89
|
+
month = value[4..5].to_i
|
|
90
|
+
day = value[6..7].to_i
|
|
91
|
+
hour = value[9..10].to_i
|
|
92
|
+
minute = value[11..12].to_i
|
|
93
|
+
second = value[13..14].to_i
|
|
94
|
+
|
|
95
|
+
Time.utc(year, month, day, hour, minute, second)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Format a Time object to timestamp format
|
|
99
|
+
#
|
|
100
|
+
# @param time [Time] The time to format
|
|
101
|
+
# @return [String] Timestamp in "YYYYMMDD-HHMMSS" format
|
|
102
|
+
def format_timestamp(time)
|
|
103
|
+
time = time.utc if time.respond_to?(:utc)
|
|
104
|
+
time.strftime("%Y%m%d-%H%M%S")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../commands/config_command"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module B36ts
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Show current configuration
|
|
10
|
+
class Config < Ace::Support::Cli::Command
|
|
11
|
+
include Ace::Support::Cli::Base
|
|
12
|
+
|
|
13
|
+
desc "Show current configuration"
|
|
14
|
+
|
|
15
|
+
option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
|
|
16
|
+
|
|
17
|
+
example [
|
|
18
|
+
" # Show basic config",
|
|
19
|
+
"--verbose # Show full config with sources"
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def call(**options)
|
|
23
|
+
Ace::B36ts::Commands::ConfigCommand.execute(options)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../commands/decode_command"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module B36ts
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Decode a compact ID to a timestamp
|
|
10
|
+
class Decode < Ace::Support::Cli::Command
|
|
11
|
+
include Ace::Support::Cli::Base
|
|
12
|
+
|
|
13
|
+
desc "Decode a compact ID (2-8 characters) to a timestamp"
|
|
14
|
+
|
|
15
|
+
argument :compact_id, required: true, desc: "2-8 character compact ID to decode (auto-detects format)"
|
|
16
|
+
option :year_zero, type: :integer, aliases: ["-y"], desc: "Base year for decoding (default: 2000)"
|
|
17
|
+
option :format, type: :string, aliases: ["-f"], desc: "Output format (readable, iso, timestamp)"
|
|
18
|
+
option :split, type: :boolean, desc: "Force hierarchical split decoding"
|
|
19
|
+
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
20
|
+
option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
|
|
21
|
+
option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
|
|
22
|
+
|
|
23
|
+
example [
|
|
24
|
+
"i5 # Decode 2-char month ID",
|
|
25
|
+
"i5v # Decode 3-char week ID (3rd char 31-35)",
|
|
26
|
+
"i50 # Decode 3-char day ID (3rd char 0-30)",
|
|
27
|
+
"i50j # Decode 4-char 40min ID",
|
|
28
|
+
"i50jj3 # Decode 6-char 2sec ID",
|
|
29
|
+
"i50jj3z # Decode 7-char 50ms ID",
|
|
30
|
+
"i50jj3zz # Decode 8-char ms ID",
|
|
31
|
+
"i50jj3 --format iso # Decode to ISO format",
|
|
32
|
+
"i50jj3 --format timestamp # Decode to YYYYMMDD-HHMMSS",
|
|
33
|
+
"i5/1/5/j/j3 # Decode split path (auto-detect separators)",
|
|
34
|
+
"i515jj3 --split # Decode split full string"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
def call(compact_id:, **options)
|
|
38
|
+
# Convert numeric options from strings to integers
|
|
39
|
+
coerce_types(options, year_zero: :integer)
|
|
40
|
+
|
|
41
|
+
Ace::B36ts::Commands::DecodeCommand.execute(compact_id, options)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../commands/encode_command"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module B36ts
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Encode a timestamp to a 6-character compact ID
|
|
10
|
+
class Encode < Ace::Support::Cli::Command
|
|
11
|
+
include Ace::Support::Cli::Base
|
|
12
|
+
|
|
13
|
+
desc "Encode a timestamp to a compact ID (2-8 characters)"
|
|
14
|
+
|
|
15
|
+
argument :timestamp, required: false, desc: "Timestamp to encode (ISO, readable, 'now', or empty for current time)"
|
|
16
|
+
option :format, type: :string, aliases: ["-f"], desc: "Output format: 2sec (default), month, week, day, 40min, 50ms, ms"
|
|
17
|
+
option :count, type: :integer, aliases: ["-n"], desc: "Generate N sequential IDs starting from timestamp"
|
|
18
|
+
option :split, type: :string, desc: "Split levels for hierarchical output (month,week,day,block)"
|
|
19
|
+
option :path_only, type: :boolean, desc: "Output only the split path"
|
|
20
|
+
option :json, type: :boolean, desc: "Output split data as JSON (works with --split or --count)"
|
|
21
|
+
option :year_zero, type: :integer, aliases: ["-y"], desc: "Base year for encoding (default: 2000)"
|
|
22
|
+
option :quiet, type: :boolean, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
23
|
+
option :verbose, type: :boolean, aliases: ["-v"], desc: "Show verbose output"
|
|
24
|
+
option :debug, type: :boolean, aliases: ["-d"], desc: "Show debug output"
|
|
25
|
+
|
|
26
|
+
example [
|
|
27
|
+
"'2025-01-06 12:30:00' # Encode readable timestamp",
|
|
28
|
+
"now # Encode current time",
|
|
29
|
+
"--format day '2025-01-06' # Encode to day format",
|
|
30
|
+
"--format month now # Encode to month format",
|
|
31
|
+
"--format 40min now # Encode to 40min format",
|
|
32
|
+
"--format 50ms now # Encode to 50ms format",
|
|
33
|
+
"--count 10 --format ms now # Generate 10 sequential ms-precision IDs",
|
|
34
|
+
"-n 5 --format day now # Generate 5 consecutive day IDs",
|
|
35
|
+
"--count 3 --format 2sec --json now # Generate 3 IDs as JSON array",
|
|
36
|
+
"--split month,week now # Encode to hierarchical split output",
|
|
37
|
+
"--split month,week now --path-only # Output only the path",
|
|
38
|
+
"--split month,day now --json # Output JSON split data",
|
|
39
|
+
"--year-zero 2025 '2025-01-06' # Encode with custom base year"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def call(timestamp: nil, **options)
|
|
43
|
+
# Convert numeric options from strings to integers
|
|
44
|
+
coerce_types(options, year_zero: :integer, count: :integer)
|
|
45
|
+
|
|
46
|
+
Ace::B36ts::Commands::EncodeCommand.execute(timestamp, options)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "ace/core"
|
|
5
|
+
require_relative "cli/commands/encode"
|
|
6
|
+
require_relative "cli/commands/decode"
|
|
7
|
+
require_relative "cli/commands/config"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module B36ts
|
|
12
|
+
# CLI interface for ace-b36ts using ace-support-cli
|
|
13
|
+
#
|
|
14
|
+
# This follows the Hanami pattern with all commands in CLI::Commands:: namespace.
|
|
15
|
+
#
|
|
16
|
+
# @example Encode a timestamp
|
|
17
|
+
# $ ace-b36ts encode "2025-01-06 12:30:00"
|
|
18
|
+
# i50jj3
|
|
19
|
+
#
|
|
20
|
+
# @example Decode a compact ID
|
|
21
|
+
# $ ace-b36ts decode i50jj3
|
|
22
|
+
# 2025-01-06 12:30:00 UTC
|
|
23
|
+
#
|
|
24
|
+
# @example Show configuration
|
|
25
|
+
# $ ace-b36ts config
|
|
26
|
+
# year_zero: 2000
|
|
27
|
+
# alphabet: 0123456789abcdefghijklmnopqrstuvwxyz
|
|
28
|
+
#
|
|
29
|
+
module CLI
|
|
30
|
+
extend Ace::Support::Cli::RegistryDsl
|
|
31
|
+
|
|
32
|
+
PROGRAM_NAME = "ace-b36ts"
|
|
33
|
+
|
|
34
|
+
REGISTERED_COMMANDS = [
|
|
35
|
+
["encode", "Encode timestamp to compact ID"],
|
|
36
|
+
["decode", "Decode compact ID to timestamp"],
|
|
37
|
+
["config", "Show current configuration"]
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
HELP_EXAMPLES = [
|
|
41
|
+
"ace-b36ts encode # Generate ID from now",
|
|
42
|
+
"ace-b36ts encode 2024-01-15T10:30:00Z # Encode specific time",
|
|
43
|
+
"ace-b36ts decode abc123 # Decode ID to timestamp"
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# Register commands (Hanami pattern: CLI::Commands::*)
|
|
47
|
+
register "encode", Commands::Encode
|
|
48
|
+
register "decode", Commands::Decode
|
|
49
|
+
register "config", Commands::Config
|
|
50
|
+
|
|
51
|
+
# Version command
|
|
52
|
+
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
53
|
+
gem_name: "ace-b36ts",
|
|
54
|
+
version: Ace::B36ts::VERSION
|
|
55
|
+
)
|
|
56
|
+
register "version", version_cmd
|
|
57
|
+
register "--version", version_cmd
|
|
58
|
+
|
|
59
|
+
# Help command
|
|
60
|
+
help_cmd = Ace::Support::Cli::HelpCommand.build(
|
|
61
|
+
program_name: PROGRAM_NAME,
|
|
62
|
+
version: Ace::B36ts::VERSION,
|
|
63
|
+
commands: REGISTERED_COMMANDS,
|
|
64
|
+
examples: HELP_EXAMPLES
|
|
65
|
+
)
|
|
66
|
+
register "help", help_cmd
|
|
67
|
+
register "--help", help_cmd
|
|
68
|
+
register "-h", help_cmd
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module B36ts
|
|
5
|
+
module Commands
|
|
6
|
+
# Command to display current configuration
|
|
7
|
+
#
|
|
8
|
+
# @example Usage
|
|
9
|
+
# ConfigCommand.execute
|
|
10
|
+
# # Output:
|
|
11
|
+
# # year_zero: 2000
|
|
12
|
+
# # alphabet: 0123456789abcdefghijklmnopqrstuvwxyz
|
|
13
|
+
#
|
|
14
|
+
class ConfigCommand
|
|
15
|
+
class << self
|
|
16
|
+
# Execute the config command
|
|
17
|
+
#
|
|
18
|
+
# @param options [Hash] Command options
|
|
19
|
+
# @option options [Boolean] :verbose Show additional config details
|
|
20
|
+
# @return [Integer] Exit code (0 for success)
|
|
21
|
+
def execute(options = {})
|
|
22
|
+
config = Molecules::ConfigResolver.resolve
|
|
23
|
+
|
|
24
|
+
puts "Current ace-b36ts configuration:"
|
|
25
|
+
puts ""
|
|
26
|
+
puts " year_zero: #{config[:year_zero]}"
|
|
27
|
+
puts " alphabet: #{config[:alphabet]}"
|
|
28
|
+
|
|
29
|
+
if options[:verbose]
|
|
30
|
+
puts ""
|
|
31
|
+
puts "Configuration sources (in order of precedence):"
|
|
32
|
+
puts " 1. Runtime options (passed to commands)"
|
|
33
|
+
puts " 2. Project config: .ace/b36ts/config.yml"
|
|
34
|
+
puts " 3. User config: ~/.ace/b36ts/config.yml"
|
|
35
|
+
puts " 4. Gem defaults: .ace-defaults/b36ts/config.yml"
|
|
36
|
+
puts ""
|
|
37
|
+
puts "Year range: #{config[:year_zero]} to #{config[:year_zero] + 107}"
|
|
38
|
+
puts "ID length: 6 characters (Base36)"
|
|
39
|
+
puts "Time precision: ~1.85 seconds"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
0
|
|
43
|
+
rescue => e
|
|
44
|
+
warn "Error displaying config: #{e.message}"
|
|
45
|
+
warn e.backtrace.first(5).join("\n") if Ace::B36ts.debug?
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module B36ts
|
|
5
|
+
module Commands
|
|
6
|
+
# Command to decode a compact ID to a timestamp
|
|
7
|
+
#
|
|
8
|
+
# @example Usage
|
|
9
|
+
# DecodeCommand.execute("i50jj3")
|
|
10
|
+
# # => "2025-01-06 12:30:00 UTC"
|
|
11
|
+
#
|
|
12
|
+
class DecodeCommand
|
|
13
|
+
class << self
|
|
14
|
+
# Execute the decode command
|
|
15
|
+
#
|
|
16
|
+
# @param compact_id [String] 2-8 character compact ID to decode
|
|
17
|
+
# @param options [Hash] Command options
|
|
18
|
+
# @option options [Integer] :year_zero Override year_zero config
|
|
19
|
+
# @option options [String] :format Output format (:iso, :timestamp, :readable)
|
|
20
|
+
# @option options [Boolean] :quiet Suppress config summary output
|
|
21
|
+
# @return [Integer] Exit code (0 for success, 1 for error)
|
|
22
|
+
def execute(compact_id, options = {})
|
|
23
|
+
config = Molecules::ConfigResolver.resolve(options)
|
|
24
|
+
|
|
25
|
+
display_config_summary("decode", config, options)
|
|
26
|
+
|
|
27
|
+
# Use split decoding for hierarchical paths, otherwise auto-detect
|
|
28
|
+
time = if options[:split] || contains_split_separator?(compact_id)
|
|
29
|
+
Atoms::CompactIdEncoder.decode_path(
|
|
30
|
+
compact_id,
|
|
31
|
+
year_zero: config[:year_zero],
|
|
32
|
+
alphabet: config[:alphabet]
|
|
33
|
+
)
|
|
34
|
+
else
|
|
35
|
+
Atoms::CompactIdEncoder.decode_auto(
|
|
36
|
+
compact_id,
|
|
37
|
+
year_zero: config[:year_zero],
|
|
38
|
+
alphabet: config[:alphabet]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
output = format_output(time, options[:format])
|
|
43
|
+
puts output
|
|
44
|
+
0
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
warn "Error: #{e.message}"
|
|
47
|
+
raise
|
|
48
|
+
rescue => e
|
|
49
|
+
warn "Error decoding compact ID: #{e.message}"
|
|
50
|
+
warn e.backtrace.first(5).join("\n") if Ace::B36ts.debug?
|
|
51
|
+
raise
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Format the time output based on requested format
|
|
57
|
+
#
|
|
58
|
+
# @param time [Time] Decoded time
|
|
59
|
+
# @param format [Symbol, String, nil] Output format
|
|
60
|
+
# @return [String] Formatted time string
|
|
61
|
+
def format_output(time, format)
|
|
62
|
+
case format&.to_sym
|
|
63
|
+
when :iso
|
|
64
|
+
time.iso8601
|
|
65
|
+
when :timestamp
|
|
66
|
+
Atoms::Formats.format_timestamp(time)
|
|
67
|
+
when :readable
|
|
68
|
+
time.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
69
|
+
else
|
|
70
|
+
# Default: readable format
|
|
71
|
+
time.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Display configuration summary (unless quiet mode)
|
|
76
|
+
#
|
|
77
|
+
# @param command [String] Command name
|
|
78
|
+
# @param config [Hash] Resolved configuration
|
|
79
|
+
# @param options [Hash] Command options
|
|
80
|
+
def display_config_summary(command, config, options)
|
|
81
|
+
return if options[:quiet]
|
|
82
|
+
|
|
83
|
+
require "ace/core"
|
|
84
|
+
Ace::Core::Atoms::ConfigSummary.display(
|
|
85
|
+
command: command,
|
|
86
|
+
config: config,
|
|
87
|
+
defaults: Molecules::ConfigResolver::FALLBACK_DEFAULTS,
|
|
88
|
+
options: options,
|
|
89
|
+
quiet: false
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Detect split separators in input
|
|
94
|
+
#
|
|
95
|
+
# @param value [String] Input to check
|
|
96
|
+
# @return [Boolean] True if split separators are present
|
|
97
|
+
def contains_split_separator?(value)
|
|
98
|
+
value.is_a?(String) && value.match?(/[\/\\:]/)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|