agent-session_context 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +268 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +146 -0
  7. data/lib/agent/session_context/cli/options.rb +213 -0
  8. data/lib/agent/session_context/cli.rb +229 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/prompt.rb +40 -0
  18. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  19. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  20. data/lib/agent/session_context/renderers/json.rb +13 -0
  21. data/lib/agent/session_context/renderers/json_lines.rb +13 -0
  22. data/lib/agent/session_context/renderers/markdown.rb +126 -0
  23. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  24. data/lib/agent/session_context/renderers/text.rb +122 -0
  25. data/lib/agent/session_context/semantic_categories.rb +89 -0
  26. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  27. data/lib/agent/session_context/semantic_schema.rb +75 -0
  28. data/lib/agent/session_context/session_resolver.rb +147 -0
  29. data/lib/agent/session_context/snapshot.rb +128 -0
  30. data/lib/agent/session_context/source_ref.rb +46 -0
  31. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  32. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  33. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  34. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  35. data/lib/agent/session_context/summarizers.rb +35 -0
  36. data/lib/agent/session_context/summary_parser.rb +219 -0
  37. data/lib/agent/session_context/transcript.rb +236 -0
  38. data/lib/agent/session_context/version.rb +7 -0
  39. data/lib/agent/session_context.rb +56 -0
  40. metadata +112 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 354885a52b8cbdd91468f869bb75a09850c1cb9b065f5c05d87f2ec83d70e9cd
4
+ data.tar.gz: c0bdeac8d7994aa514e01b7b08b57273d843e67359ce29875447925bfe665a23
5
+ SHA512:
6
+ metadata.gz: d3be9d23e7d1ae974cdc4f516a61159e586e88573a2fea32e8d28137220d298036cc4a461f87c98c29787c13a5ee51789e45e7e55df4858db22a57bde529455b
7
+ data.tar.gz: 2cbf65397f9bd18dda3fdfda24fc49f6633946d66f82b0589d221a5a11afb63415c1cbe222f5be58e1a6009012e700ad5189570271c030ee9b24a08e4ac745ba
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-09-01
4
+
5
+ - Use the `agent-session_context` distribution, `Agent::SessionContext`
6
+ namespace, and `agent-session-context` executable to avoid collisions with
7
+ the existing `agent-context` gem and `Agent::Context` namespace.
8
+ - Let `--current` choose the uniquely latest session metadata timestamp only
9
+ when no supported session environment identifier is present. Present
10
+ identifiers keep their validation and errors; `--agent` narrows disk
11
+ fallback, empty stores raise `CurrentSessionUnavailable`, the CLI warns with
12
+ the selected UID, and exact ties refuse with explicit-session guidance.
13
+ - Make `show` include exact user prompts and a deduplicated injected-context
14
+ inventory, with explicit `--include-injected`/`include_injected: true` access
15
+ to full injected text and documented raw-transcript exclusions.
16
+ - Add real Claude Code and Codex CLI JSONL fixtures for the public `show`,
17
+ `prompts`, and `summarize` contract.
18
+ - Verify prompt filtering against injected environment, AGENTS, slash-command,
19
+ and tool-result records while keeping observed file/tool evidence grounded to
20
+ source refs.
21
+ - Bound summarizer subprocess stdout/stderr streaming to independent `1 MiB`
22
+ caps, add per-call timeout controls across config/API/CLI, and package the
23
+ shared subprocess/config support.
24
+ - Verify the Codex summarization backend against a live recorded session.
25
+ - Document the recorded-evidence model, CLI/API surface, privacy boundaries,
26
+ and provider boundaries.
27
+ - Include source, changelog, and issue-tracker links for the public GitHub
28
+ repository in the gem metadata.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Lucian Ghinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,268 @@
1
+ # Agent Session Context
2
+
3
+ [![Build](https://github.com/lucianghinda/agent-session-context/actions/workflows/main.yml/badge.svg)](https://github.com/lucianghinda/agent-session-context/actions/workflows/main.yml)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](https://www.ruby-lang.org/)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+
7
+ Inspect and summarize recorded Claude Code and Codex sessions.
8
+
9
+ ## Installation
10
+
11
+ Use Ruby 3.2 or newer.
12
+
13
+ Add this line to your application's **Gemfile**:
14
+
15
+ ```ruby
16
+ gem "agent-session_context"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ Show the latest recorded session:
28
+
29
+ ```bash
30
+ agent-session-context show --current
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Basic Usage
36
+
37
+ Show an exact session:
38
+
39
+ ```bash
40
+ agent-session-context show codex:SESSION_ID
41
+ ```
42
+
43
+ Disambiguate a bare identifier:
44
+
45
+ ```bash
46
+ agent-session-context show SESSION_ID --agent codex
47
+ ```
48
+
49
+ List exact user prompts:
50
+
51
+ ```bash
52
+ agent-session-context prompts --current
53
+ ```
54
+
55
+ Render prompts as JSON Lines:
56
+
57
+ ```bash
58
+ agent-session-context prompts --current --format jsonl
59
+ ```
60
+
61
+ ### Current Sessions
62
+
63
+ Use these variables for `--current`, in order:
64
+
65
+ 1. `AGENT_SESSION_ID` with `AGENT_NAME`
66
+ 2. `CLAUDE_CODE_SESSION_ID`
67
+ 3. `CODEX_SESSION_ID`
68
+ 4. `CODEX_THREAD_ID`
69
+
70
+ Without variables, let the command select the unique newest session metadata.
71
+
72
+ Pass `--agent` to restrict that disk search.
73
+
74
+ Keep present identifiers authoritative.
75
+
76
+ Never trigger fallback after validating a present identifier.
77
+
78
+ Clear conflicting Claude and Codex identifiers before retrying.
79
+
80
+ Expect missing targets, empty stores, and timestamp ties to fail.
81
+
82
+ Read the selected UID from the CLI warning.
83
+
84
+ Treat disk selection as recency, not live context.
85
+
86
+ ### Local Context
87
+
88
+ Include deduplicated injected text explicitly:
89
+
90
+ ```bash
91
+ agent-session-context show --current --include-injected
92
+ ```
93
+
94
+ Review exact prompts and injected text before sharing them.
95
+
96
+ Expect `show` to exclude assistant messages, thinking, tool-result bodies, and raw envelopes.
97
+
98
+ Use `show` and `prompts` without starting a model.
99
+
100
+ ### Summaries
101
+
102
+ Create a grounded summary:
103
+
104
+ ```bash
105
+ agent-session-context summarize --current
106
+ ```
107
+
108
+ Choose a backend and timeout:
109
+
110
+ ```bash
111
+ agent-session-context summarize --current --using codex --timeout 45
112
+ ```
113
+
114
+ Codex summarization was tested successfully against a live recorded session.
115
+
116
+ Expect summaries to cite recorded source references.
117
+
118
+ Expect summaries to exclude thinking, tool results, injected blocks, and raw records.
119
+
120
+ Treat Codex filesystem access as read-only, not hermetic.
121
+
122
+ ### Ruby API
123
+
124
+ Resolve and inspect a session:
125
+
126
+ ```ruby
127
+ require "agent/session_context"
128
+
129
+ session = Agent::SessionContext.resolve("codex:SESSION_ID")
130
+ snapshot = Agent::SessionContext.show(session)
131
+ ```
132
+
133
+ Inspect the newest recorded Codex session:
134
+
135
+ ```ruby
136
+ session = Agent::SessionContext.current(agent: :codex, env: {})
137
+ prompts = Agent::SessionContext.prompts(session)
138
+ ```
139
+
140
+ Create a summary with built-in settings:
141
+
142
+ ```ruby
143
+ summary = Agent::SessionContext.summarize(session, using: :codex, timeout: 45)
144
+ ```
145
+
146
+ Use a custom summarizer:
147
+
148
+ ```ruby
149
+ summary = Agent::SessionContext.summarize(
150
+ session,
151
+ summarizer: ->(prompt:, schema:) { call_your_model(prompt, schema) }
152
+ )
153
+ ```
154
+
155
+ Replace `call_your_model` with your adapter.
156
+
157
+ Return a JSON string matching the provided schema.
158
+
159
+ Pass either `summarizer:` or `timeout:`, never both.
160
+
161
+ ### Supported Public Ruby API
162
+
163
+ `Agent::SessionContext.resolve` and `Agent::SessionContext.current` return `Agent::Sessions::Session`.
164
+
165
+ `Agent::SessionContext.show` returns an `Agent::SessionContext::Snapshot` whose collections contain `Agent::SessionContext::Prompt`, `Agent::SessionContext::InjectedContext`, `Agent::SessionContext::Item`, and `Agent::SessionContext::SourceRef` values as applicable.
166
+
167
+ `Agent::SessionContext.prompts` returns an array of `Agent::SessionContext::Prompt` values.
168
+
169
+ `Agent::SessionContext.summarize` returns an `Agent::SessionContext::Snapshot` populated with summary `Agent::SessionContext::Item` values and summary metadata.
170
+
171
+ `Agent::SessionContext::Snapshot`, `Agent::SessionContext::Prompt`, `Agent::SessionContext::InjectedContext`, `Agent::SessionContext::Item`, and `Agent::SessionContext::SourceRef` are part of the supported public data model.
172
+
173
+ `Agent::SessionContext::VERSION` is public.
174
+
175
+ `Agent::SessionContext::CLI::FORMATS` is the supported frozen list of CLI output format names.
176
+
177
+ The public error classes listed in [Errors](#errors) are part of the compatibility contract.
178
+
179
+ ### Internal Architecture
180
+
181
+ `resolve -> capture -> extract/collect -> optionally summarize -> build snapshot -> render`.
182
+
183
+ `show` can expose injected text only when you opt into `include_injected`, while `summarize` keeps injected blocks and tool-result bodies out of the model prompt.
184
+
185
+ Except for `Agent::SessionContext::CLI::FORMATS`, the CLI implementation, builders, collectors, parsers, runners, renderers, and built-in summarizer adapters are internal details without compatibility guarantees.
186
+
187
+ ## Options
188
+
189
+ | Option | Description |
190
+ |---|---|
191
+ | `--current` | Use environment identity, then the newest disk metadata. |
192
+ | `--agent claude\|codex` | Restrict explicit lookup or disk fallback. |
193
+ | `--format FORMAT` | Choose text, Markdown, JSON, or JSON Lines when supported. |
194
+ | `--include-injected` | Include deduplicated injected text with `show`. |
195
+ | `--using BACKEND` | Choose `auto`, `claude`, or `codex` for summaries. |
196
+ | `--timeout SECONDS` | Set each provider call timeout from 1 through 3600 seconds. |
197
+
198
+ Run `agent-session-context help` for command details.
199
+
200
+ ### Configuration
201
+
202
+ Expect only `summarize` to load configuration.
203
+
204
+ Configuration paths retain the original `agent-context` name for compatibility.
205
+
206
+ Create `.agent-context.yml` in the recorded project:
207
+
208
+ ```yaml
209
+ summarize:
210
+ timeout_seconds: 300
211
+ ```
212
+
213
+ Set user defaults in `$XDG_CONFIG_HOME/agent_context/config.yml`.
214
+
215
+ Otherwise, use `$HOME/.config/agent_context/config.yml`.
216
+
217
+ Use `XDG_CONFIG_HOME` exclusively when it contains an absolute path.
218
+
219
+ Let project configuration override user defaults.
220
+
221
+ Pass `--timeout` to override both files.
222
+
223
+ Use finite numbers from `1` through `3600`.
224
+
225
+ Apply the timeout to each provider call.
226
+
227
+ ### Output Formats
228
+
229
+ | Command | Formats |
230
+ |---|---|
231
+ | `show` | `text`, `markdown`, `json` |
232
+ | `prompts` | `text`, `markdown`, `json`, `jsonl` |
233
+ | `summarize` | `text`, `markdown`, `json` |
234
+
235
+ ### Errors
236
+
237
+ Handle these public errors:
238
+
239
+ - `Agent::SessionContext::SessionNotFound`
240
+ - `Agent::SessionContext::AmbiguousSession`
241
+ - `Agent::SessionContext::CurrentSessionUnavailable`
242
+ - `Agent::SessionContext::UnsupportedAgent`
243
+ - `Agent::SessionContext::ConfigurationError`
244
+ - `Agent::SessionContext::SummarizerUnavailable`
245
+ - `Agent::SessionContext::SummarizerFailed`
246
+ - `Agent::SessionContext::InvalidSummary`
247
+
248
+ ## Contributing
249
+
250
+ Fork the repository and create a branch.
251
+
252
+ Run the test suite:
253
+
254
+ ```bash
255
+ bundle exec rake test
256
+ ```
257
+
258
+ Open a pull request with tests and documentation.
259
+
260
+ Follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
261
+
262
+ Report bugs through [GitHub Issues](https://github.com/lucianghinda/agent-session-context/issues).
263
+
264
+ ## License
265
+
266
+ Use the gem under the MIT License.
267
+
268
+ See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "agent/session_context"
5
+
6
+ exit Agent::SessionContext::CLI.new(ARGV).run
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class Builder
6
+ PromptsResult = Data.define(:prompts, :reader_warnings) do
7
+ def partial_capture?
8
+ !reader_warnings.empty?
9
+ end
10
+ end
11
+
12
+ def initialize(
13
+ catalog: Agent::Sessions,
14
+ now: Time.now,
15
+ collector: EvidenceCollector.new,
16
+ prompt_extractor: PromptExtractor.new,
17
+ injected_context_collector: InjectedContextCollector.new
18
+ )
19
+ @catalog = catalog
20
+ @now = now
21
+ @collector = collector
22
+ @prompt_extractor = prompt_extractor
23
+ @injected_context_collector = injected_context_collector
24
+ end
25
+
26
+ def show(session, include_injected: false)
27
+ validate_include_injected!(include_injected)
28
+ transcript = capture(session)
29
+ observed = @collector.call(transcript)
30
+
31
+ build_snapshot(
32
+ session:,
33
+ transcript:,
34
+ observed:,
35
+ prompts: @prompt_extractor.call(transcript),
36
+ injected_context: @injected_context_collector.call(transcript, include_text: include_injected),
37
+ warnings: transcript.warnings,
38
+ summary_metadata: base_metadata(transcript)
39
+ )
40
+ end
41
+
42
+ def prompts(session)
43
+ prompts_result(session).prompts
44
+ end
45
+
46
+ def prompts_result(session)
47
+ transcript = capture(session)
48
+ PromptsResult.new(
49
+ prompts: @prompt_extractor.call(transcript),
50
+ reader_warnings: transcript.warnings
51
+ )
52
+ end
53
+
54
+ def summarize(session, summarizer:)
55
+ transcript = capture(session)
56
+ observed = @collector.call(transcript)
57
+ semantic = SemanticPipeline.new(backend: summarizer).call(transcript:, observed:)
58
+ semantic_collections, unknown_warnings = collect_semantic_items(semantic.items)
59
+
60
+ build_snapshot(
61
+ session: session,
62
+ transcript: transcript,
63
+ observed: observed,
64
+ semantic_collections: semantic_collections,
65
+ warnings: transcript.warnings + semantic.warnings + unknown_warnings,
66
+ summary_metadata: semantic.metadata.merge(base_metadata(transcript))
67
+ )
68
+ end
69
+
70
+ private
71
+
72
+ def validate_include_injected!(value)
73
+ return if value.equal?(true) || value.equal?(false)
74
+
75
+ raise ArgumentError, "include_injected must be true or false"
76
+ end
77
+
78
+ def capture(session)
79
+ Transcript.capture(session, reader: @catalog.read(session), now: @now)
80
+ end
81
+
82
+ def collect_semantic_items(items)
83
+ collections = empty_semantic_collections
84
+ warnings = []
85
+
86
+ items.each do |item|
87
+ field = SemanticCategories.lookup(item.kind)&.snapshot_field
88
+ unless field
89
+ warnings << "Dropped unknown semantic kind #{item.kind.inspect}"
90
+ next
91
+ end
92
+
93
+ collections[field] << item
94
+ end
95
+
96
+ [collections.transform_values(&:freeze).freeze, warnings.freeze]
97
+ end
98
+
99
+ def empty_semantic_collections
100
+ SemanticCategories.snapshot_fields.to_h do |field|
101
+ [field, []]
102
+ end
103
+ end
104
+
105
+ def build_snapshot(
106
+ session:,
107
+ transcript:,
108
+ observed:,
109
+ warnings:,
110
+ summary_metadata:,
111
+ prompts: [],
112
+ injected_context: [],
113
+ semantic_collections: empty_semantic_collections.transform_values(&:freeze).freeze
114
+ )
115
+ Snapshot.new(
116
+ session_uid: session.uid,
117
+ agent: session.agent,
118
+ project_path: session.project_path,
119
+ captured_at: transcript.captured_at,
120
+ message_count: transcript.entries.length,
121
+ prompts:,
122
+ injected_context:,
123
+ files: observed.files,
124
+ documents: observed.documents,
125
+ tool_activity: observed.tool_activity,
126
+ **semantic_snapshot_attributes(semantic_collections),
127
+ warnings: warnings,
128
+ summary_metadata: summary_metadata
129
+ )
130
+ end
131
+
132
+ def semantic_snapshot_attributes(semantic_collections)
133
+ SemanticCategories.snapshot_fields.to_h do |field|
134
+ [field, semantic_collections.fetch(field)]
135
+ end
136
+ end
137
+
138
+ def base_metadata(transcript)
139
+ metadata = { injected_parts_filtered: transcript.entries.sum { |entry| entry.parts.count(&:injected) } }
140
+ reader_warning_count = transcript.warnings.length
141
+ metadata[:reader_warning_count] = reader_warning_count if reader_warning_count.positive?
142
+ metadata
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Agent
6
+ module SessionContext
7
+ class CLI
8
+ class Options
9
+ AGENTS = %w[claude codex].freeze
10
+ BACKENDS = %w[auto claude codex].freeze
11
+ OPTION_DEFINITIONS = {
12
+ current: {
13
+ long: "--current",
14
+ value: false,
15
+ register: lambda do |parser, options|
16
+ parser.on("--current") { options[:current] = true }
17
+ end
18
+ }.freeze,
19
+ agent: {
20
+ long: "--agent",
21
+ value: true,
22
+ register: lambda do |parser, options|
23
+ parser.on("--agent AGENT", AGENTS) { |value| options[:agent] = value.to_sym }
24
+ end
25
+ }.freeze,
26
+ format: {
27
+ long: "--format",
28
+ value: true,
29
+ register: lambda do |parser, options|
30
+ parser.on("--format FORMAT", CLI::FORMATS) { |value| options[:format] = value.to_sym }
31
+ end
32
+ }.freeze,
33
+ using: {
34
+ long: "--using",
35
+ value: true,
36
+ register: lambda do |parser, options|
37
+ parser.on("--using BACKEND", BACKENDS) { |value| options[:using] = value.to_sym }
38
+ end
39
+ }.freeze,
40
+ timeout: {
41
+ long: "--timeout",
42
+ value: true,
43
+ register: lambda do |parser, options|
44
+ parser.on("--timeout SECONDS") { |value| options[:timeout] = parse_timeout_argument(value) }
45
+ end
46
+ }.freeze,
47
+ include_injected: {
48
+ long: "--include-injected",
49
+ value: false,
50
+ register: lambda do |parser, options|
51
+ parser.on("--include-injected") { options[:include_injected] = true }
52
+ end
53
+ }.freeze
54
+ }.freeze
55
+ COMMANDS = {
56
+ show: { formats: %i[text markdown json], options: %i[current agent format include_injected] }.freeze,
57
+ prompts: { formats: %i[text markdown json jsonl], options: %i[current agent format] }.freeze,
58
+ summarize: { formats: %i[text markdown json], options: %i[current agent format using timeout] }.freeze,
59
+ help: { formats: nil, options: [].freeze }.freeze,
60
+ version: { formats: nil, options: [].freeze }.freeze
61
+ }.transform_values do |descriptor|
62
+ option_names = descriptor.fetch(:options)
63
+ long_options = option_names.each_with_object({}) do |name, rules|
64
+ definition = OPTION_DEFINITIONS.fetch(name)
65
+ rules[definition.fetch(:long)] = definition.fetch(:value) ? :value : :flag
66
+ end.freeze
67
+
68
+ descriptor.merge(
69
+ formats: descriptor[:formats]&.freeze,
70
+ options: option_names.freeze,
71
+ long_options:
72
+ ).freeze
73
+ end.freeze
74
+ private_constant :AGENTS, :BACKENDS, :OPTION_DEFINITIONS, :COMMANDS
75
+
76
+ class << self
77
+ def parse(arguments, command:)
78
+ descriptor = command_descriptor(command)
79
+ options = default_options
80
+ validate_argument_encoding!(arguments)
81
+ validate_exact_long_options!(arguments, descriptor:)
82
+ remaining = parser(options, descriptor:).permute(arguments.dup)
83
+
84
+ validate_selection!(options, remaining, command:, descriptor:)
85
+ options.merge(identifier: remaining.first).freeze
86
+ end
87
+
88
+ def hinted_format(arguments)
89
+ limit = arguments.index("--") || arguments.length
90
+ effective = :text
91
+ index = 0
92
+
93
+ while index < limit
94
+ token = arguments[index]
95
+
96
+ if token == "--format"
97
+ candidate = arguments[index + 1]
98
+ effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
99
+ index += 2
100
+ next
101
+ end
102
+
103
+ if token.start_with?("--format=")
104
+ candidate = token.split("=", 2).last
105
+ effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
106
+ end
107
+
108
+ index += 1
109
+ end
110
+
111
+ effective
112
+ end
113
+
114
+ def parse_no_args(arguments, command:)
115
+ descriptor = command_descriptor(command)
116
+ validate_argument_encoding!(arguments)
117
+ validate_exact_long_options!(arguments, descriptor:)
118
+ remaining = OptionParser.new.permute(arguments.dup)
119
+ raise OptionParser::ParseError, "unexpected arguments: #{remaining.join(" ")}" unless remaining.empty?
120
+ end
121
+
122
+ private
123
+
124
+ def default_options
125
+ {
126
+ current: false,
127
+ agent: nil,
128
+ format: :text,
129
+ using: :auto,
130
+ timeout: nil,
131
+ include_injected: false
132
+ }
133
+ end
134
+
135
+ def parser(options, descriptor:)
136
+ OptionParser.new do |parser|
137
+ descriptor.fetch(:options).each do |name|
138
+ OPTION_DEFINITIONS.fetch(name).fetch(:register).call(parser, options)
139
+ end
140
+ end
141
+ end
142
+
143
+ def validate_selection!(options, remaining, command:, descriptor:)
144
+ allowed_formats = descriptor.fetch(:formats)
145
+ unless allowed_formats.include?(options[:format])
146
+ raise OptionParser::ParseError, "--format #{options[:format]} is not supported for #{command}"
147
+ end
148
+
149
+ if options[:current] && !remaining.empty?
150
+ raise OptionParser::ParseError, "SESSION and --current are mutually exclusive"
151
+ end
152
+
153
+ if remaining.length > 1
154
+ raise OptionParser::ParseError, "unexpected arguments: #{remaining.drop(1).join(" ")}"
155
+ end
156
+
157
+ raise OptionParser::ParseError, "pass SESSION or --current" if !options[:current] && remaining.empty?
158
+ end
159
+
160
+ def validate_argument_encoding!(arguments)
161
+ arguments.each do |argument|
162
+ next unless argument.is_a?(String)
163
+
164
+ candidate = argument.dup
165
+ candidate.force_encoding(Encoding::UTF_8)
166
+ raise OptionParser::InvalidArgument, "arguments must be valid UTF-8" unless candidate.valid_encoding?
167
+ end
168
+ end
169
+
170
+ def validate_exact_long_options!(arguments, descriptor:)
171
+ allowed = descriptor.fetch(:long_options)
172
+ limit = arguments.index("--") || arguments.length
173
+ index = 0
174
+
175
+ while index < limit
176
+ token = arguments[index]
177
+ if token.start_with?("--")
178
+ name, value = token.split("=", 2)
179
+ rule = allowed[name]
180
+ raise OptionParser::InvalidOption, token unless rule
181
+ raise OptionParser::InvalidOption, token if rule == :flag && !value.nil?
182
+
183
+ if rule == :value && value.nil?
184
+ index += 2
185
+ next
186
+ end
187
+ end
188
+
189
+ index += 1
190
+ end
191
+ end
192
+
193
+ def command_descriptor(command)
194
+ COMMANDS.fetch(command.to_sym)
195
+ end
196
+
197
+ def parse_timeout_argument(value)
198
+ Integer(value, 10)
199
+ rescue ArgumentError, TypeError
200
+ return Float::NAN if value.casecmp("nan").zero?
201
+ return Float::INFINITY if value.casecmp("infinity").zero? || value.casecmp("inf").zero?
202
+ return -Float::INFINITY if value.casecmp("-infinity").zero? || value.casecmp("-inf").zero?
203
+
204
+ parsed = Float(value, exception: false)
205
+ return parsed unless parsed.nil?
206
+
207
+ raise OptionParser::InvalidArgument, value
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end