activeagent 1.9.4 → 1.9.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 715323643179a060b32d624ba906586778645e818c0436d4e4efe9ea6585847e
4
- data.tar.gz: 3efe81e8a8d5357baf0f46adcfa5ce2fedd96234bb8ed603ffae63bac17bec58
3
+ metadata.gz: 21ef2f8df6b6cb8ff4d37cfa6586cad1a993714a0ca70a39c3354451c131680a
4
+ data.tar.gz: aa2b6cc445855ff76b6a22a10373887257be2e3fcfb9932360bace049b8ad1ba
5
5
  SHA512:
6
- metadata.gz: 4a0ca1235fb0e8290ad261b61d77e38b4ee87372158384644612d54e81742f9f743968c8b8fa2c4e5cbebd57b62cdba7b0b0efbede6b3ad78176a7d7cca8dc37
7
- data.tar.gz: 8fc1c553e0a008287280492dc401efccf1fdd675417985ef8acd8df3cb86ebe263a7cf6066b1740c8c387c1eb6de92ffffe428c5024b33a3ddf0464e729202a3
6
+ metadata.gz: 1650882a747d581893f75316cdd5731c4b67cb8596cc6d359b4ae9d5b7dd49daf2bfa255089ebd72eadb96064a5bb16fccc07c8613e92256f08f4f9586e91530
7
+ data.tar.gz: 0647b570ee151fd2fa231f39b9db0a5545ce5c8b3a8a23b33d4c08c25dc14fa17e9edb3c9a5718083c89bb2d4948260aa082be4de54edc91ddac76bb0680551b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,121 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.5] - 2026-10-28
9
+
10
+ The `activeagent` command line: the gem's third surface beside the dashboard
11
+ and its API, working with a dashboard over HTTP from a terminal or a CI job,
12
+ and the facade tools it needed. A run, a scenario catalog and a trace each
13
+ move between installs as one file. The framework and the dashboard engine
14
+ move to 1.9.5 together.
15
+
16
+ Upgrading: `rails generate action_agent:install` and `rails db:migrate` add
17
+ the GitHub App webhook deliveries table (below). The CLI needs `actionagent`
18
+ 1.9.5 on the dashboard for `catalogs export`, `catalogs import --preview`,
19
+ `agents versions` and `traces export` (the facade's new tools), and 1.9.3
20
+ for `evals report` (the report export); an older dashboard answers those
21
+ commands with exit code 5.
22
+
23
+ ### Added
24
+
25
+ - **Evaluation runs published while they run** (`activeagent`, `actionagent`).
26
+ `ActiveAgent::Evals::Publisher#progress` sends the results so far as a
27
+ `"status": "running"` envelope, and the collector stores them as a running
28
+ run that the Evaluations page follows live; each later delivery under the
29
+ run_id replaces the results until `#call` completes the run. Only the first
30
+ delivery is admitted and counted. A progress delivery after completion
31
+ changes nothing. The collector lists `"progress"` in the `capabilities` of
32
+ its readiness answer, and `#progress` sends nothing to a collector that does
33
+ not (`#supports_progress?`), since an older one would store the partial
34
+ report as complete.
35
+ - **Imported evaluation runs keep their dates** (`actionagent`). The
36
+ collector dates a run by its report's `metadata.finished`, which an export
37
+ carries, so an exported run imported into another install keeps its place
38
+ in the run history instead of reading as just run. A report naming no
39
+ finish time is dated on arrival, and a malformed one is refused.
40
+ - **`activeagent` command line** (`activeagent`, `actionagent`; refs #619).
41
+ The gem ships an `activeagent` executable (stdlib `OptionParser` and
42
+ `Net::HTTP`, no new dependencies) that works with a dashboard over HTTP:
43
+ `login`/`logout` (named profiles in `~/.config/activeagent/credentials.yml`,
44
+ `0600`; `ACTIVEAGENT_URL`, `ACTIVEAGENT_API_KEY` and
45
+ `ACTIVEAGENT_INGEST_KEY` override them for CI), `status`,
46
+ `evals list|runs|run|report|import`, `catalogs list|import|export`
47
+ (`import --preview` shows what a document would change, `--mode merge`
48
+ keeps what it leaves out), `agents list|versions` and
49
+ `traces list|get|export|import`, with `--json` output and distinct exit
50
+ codes. `evals run --min-pass-rate 0.8` waits for the run and exits 4
51
+ below the threshold, for CI gating. Reads and runs go through the MCP
52
+ facade's dashboard tools with a dashboard API key; the imports and
53
+ `evals report` use the endpoints the framework's own reporters post to,
54
+ with the ingest key. A run, a catalog and a trace each move between
55
+ installs as one file: `evals report RUN --format json` writes the
56
+ version-1 envelope `evals import` posts (`--format html|md` the report
57
+ page or its Markdown, for a pull request comment), `catalogs export KEY`
58
+ the canonical document (YAML, or `--format json`) that `catalogs import`
59
+ reads, and `traces export TRACE` the payload `traces import` sends to
60
+ trace ingest. See `docs/framework/cli.md`.
61
+
62
+ - **Four more dashboard tools on the MCP facade** (`actionagent`), the
63
+ API-key routes the CLI needed: `catalogs_export` (a catalog's canonical
64
+ document as YAML or JSON, whole or one product or set), `catalogs_preview`
65
+ (what `catalogs_import` would change, writing nothing), `agents_versions`
66
+ (an agent's versions with their diffs, as `GET /api/agents/:id/versions`)
67
+ and `traces_export` (one trace, whole and untruncated, as the payload
68
+ `POST <mount>/api/traces` takes, so a trace moves between installs).
69
+ `catalogs_import` takes `mode: replace|merge` as the API does. Every run
70
+ summary (`GET /api/evaluations`, the facade's `evaluations_get`,
71
+ `evaluations_run` and `evaluation_runs_get`) now carries `run_id`, the
72
+ handle `GET <mount>/api/evaluation_reports/:run_id` takes.
73
+
74
+ - **GitHub App webhooks** (`actionagent`). `POST <mount>/api/github_app/webhooks`
75
+ receives the App's deliveries, verified with `X-Hub-Signature-256` under
76
+ `ActionAgent.github_app_webhook_secret` (falling back to
77
+ `GITHUB_APP_WEBHOOK_SECRET`; unset, the endpoint answers 404), and applies
78
+ them in `ActionAgent::GithubWebhookJob`: an uninstall marks the linked
79
+ installation removed, suspend and unsuspend set and clear `suspended_at`,
80
+ accepted permissions are recorded, a repository removed from the
81
+ installation leaves the owner's selection, and a revoked App authorization
82
+ deletes the user's stored connection when connections authorize through the
83
+ App. Each `X-GitHub-Delivery` is recorded for seven days, so a redelivery
84
+ is a no-op. Ships a new migration,
85
+ `create_active_agent_github_webhook_deliveries`; run
86
+ `rails generate action_agent:install` and `rails db:migrate` to add it.
87
+
88
+ ### Fixed
89
+
90
+ - **Sandbox sign-in recognises Claude Code 2.1's authorization page**
91
+ (`actionagent`). "Sign in with your Claude subscription" on a local sandbox
92
+ stayed at "Preparing Claude's authorization page": the CLI now sends
93
+ sign-ins to `claude.com/cai/oauth/authorize` and prints the link as a
94
+ terminal hyperlink whose text repeats the URL, which the flow neither
95
+ accepted nor cut at the hyperlink's end. Both are read now, and
96
+ `claude.ai` and `platform.claude.com` still are.
97
+ - **Projects accept GitHub App installations** (`actionagent`). A dashboard
98
+ that runs only the GitHub App could link an installation but not create a
99
+ project ("GitHub is not connected"). The New Project checklist now counts a
100
+ usable installation as connected, `GET /api/projects/repositories` lists
101
+ the installations' repositories together with the OAuth connection's, and
102
+ the preflight, secret discovery and creation reach a repository through the
103
+ installation first, as its checkout does, with a token minted for reading
104
+ that one repository. Picking a repository the installation has not
105
+ selected selects it there. A removed or suspended installation answers
106
+ `installation_unavailable`.
107
+ - **Report a missing Members permission when linking an organization's
108
+ GitHub App installation** (`actionagent`). GitHub answers the membership
109
+ check with 403 while the App lacks the organization permission Members
110
+ (read); the callback now reports `members_permission` instead of
111
+ `not_admin`. The dashboard guide documents the permission, and that the
112
+ dashboard's callback URL must be the App's first.
113
+
114
+ - **Boot on Ruby 3.4 and 4.0 with bootsnap 1.24.6 or newer** (`actionagent`).
115
+ `ProviderKey#owner_record` unwrapped delegators with
116
+ `... while defined?(::Delegator) && ...`, which Prism miscompiles when the
117
+ source is compiled from a string ("argument stack underflow"); bootsnap
118
+ compiles files that way on those Rubies as its Ruby bug #22023 workaround,
119
+ so a host on bootsnap 1.26 (and local runs on Ruby 3.4.9 or 4.0.1) failed
120
+ to boot the engine. The `defined?` check now sits outside the loop, and a
121
+ test compiles every shipped Ruby file from a string.
122
+
8
123
  ## [1.9.4] - 2026-10-25
9
124
 
10
125
  ### Added
data/README.md CHANGED
@@ -181,7 +181,12 @@ telemetry:
181
181
  The generator mounts the engine at `/activeagents` — open it and every
182
182
  generation appears as a trace. See
183
183
  [docs/framework/dashboard.md](docs/framework/dashboard.md) for
184
- authentication, remote ingestion, and multi-tenant mode. The hosted
184
+ authentication, remote ingestion, and multi-tenant mode. The gem's
185
+ `activeagent` command line works with that dashboard from a terminal or a
186
+ CI job — `activeagent evals run "Support orders" --min-pass-rate 0.8` fails
187
+ a build below the threshold, and runs, scenario catalogs and traces move
188
+ between installs as JSON files; see
189
+ [docs/framework/cli.md](docs/framework/cli.md). The hosted
185
190
  platform at [activeagents.ai](https://activeagents.ai) runs this same
186
191
  engine multi-tenant, adding what a hosted product has to have — accounts,
187
192
  plans, billing, quotas and managed sandboxes; every workspace starts with
@@ -198,6 +203,7 @@ a free low-volume trial.
198
203
  - **Agent-as-Tool Delegation**: Hand work to sub-agents with declared schemas, cost/latency budgets, and swappable backends
199
204
  - **Context Management**: Maintain conversation history across interactions
200
205
  - **Structured Output**: Define JSON schemas for predictable responses
206
+ - **Command Line**: `activeagent` runs evaluations and gates CI on a pass rate from a terminal, and moves runs, scenario catalogs and traces between dashboards as JSON
201
207
 
202
208
  ## Examples
203
209
 
data/exe/activeagent ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The activeagent CLI. Loads only lib/active_agent/cli, not Rails or the
5
+ # framework. See docs/framework/cli.md.
6
+ require_relative "../lib/active_agent/cli"
7
+
8
+ exit ActiveAgent::CLI.start(ARGV)
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require "zlib"
8
+ require_relative "../evals/publisher"
9
+
10
+ module ActiveAgent
11
+ class CLI
12
+ # HTTP to one dashboard mount, with the conventions of
13
+ # ActiveAgent::Evals::Publisher: blocking, bounded timeouts, no redirects
14
+ # followed with a bearer key, and errors that never quote a response body
15
+ # beyond its sanitized +error+ string, with the keys filtered out.
16
+ #
17
+ # The dashboard authenticates two kinds of key, and this client sends
18
+ # each where it belongs:
19
+ #
20
+ # - +api_key+, a dashboard API key (Settings -> API Keys, "aa_..."), for
21
+ # the MCP facade at <mount>/mcp, whose dashboard tools back most
22
+ # commands.
23
+ # - +ingest_key+, the install's ingest key (ActionAgent.ingest_api_key,
24
+ # or the tenant's telemetry key), for the endpoints the framework's
25
+ # reporters post to: the report collector at
26
+ # <mount>/api/evaluation_reports and trace ingest at <mount>/api/traces.
27
+ # Defaults to +api_key+.
28
+ class Client
29
+ DETAIL_LIMIT = 200
30
+ JSONRPC_INVALID_PARAMS = -32602
31
+
32
+ attr_reader :url
33
+
34
+ def initialize(url:, api_key: nil, ingest_key: nil, timeout: 30, open_timeout: 10)
35
+ @uri = self.class.parse_url(url)
36
+ @url = @uri.to_s
37
+ @api_key = api_key.to_s.strip
38
+ @ingest_key = ingest_key.to_s.strip.empty? ? @api_key : ingest_key.to_s.strip
39
+ @timeout = timeout
40
+ @open_timeout = open_timeout
41
+ @request_id = 0
42
+ end
43
+
44
+ # The dashboard mount as a URI, without a trailing slash. HTTPS is
45
+ # required except on loopback hosts, as for the publisher.
46
+ def self.parse_url(url)
47
+ uri = URI.parse(url.to_s.strip.sub(%r{/+\z}, ""))
48
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
49
+ raise Error.new("The dashboard URL must be an HTTP(S) URL without credentials, query or fragment, " \
50
+ "such as https://ops.example.com/activeagents", exit_code: EXIT_USAGE)
51
+ end
52
+ unless uri.scheme == "https" || %w[localhost 127.0.0.1 ::1].include?(uri.hostname)
53
+ raise Error.new("The dashboard URL requires HTTPS except on loopback hosts", exit_code: EXIT_USAGE)
54
+ end
55
+ uri
56
+ rescue URI::InvalidURIError
57
+ raise Error.new("The dashboard URL is not a valid URL", exit_code: EXIT_USAGE)
58
+ end
59
+
60
+ def mcp_endpoint
61
+ "#{url}/mcp"
62
+ end
63
+
64
+ def reports_endpoint
65
+ "#{url}/api/evaluation_reports"
66
+ end
67
+
68
+ def traces_endpoint
69
+ "#{url}/api/traces"
70
+ end
71
+
72
+ # A Publisher for the mount's collector, authenticated with the ingest key.
73
+ def publisher
74
+ require_key!(@ingest_key, "an ingest key")
75
+ ActiveAgent::Evals::Publisher.new(api_key: @ingest_key, endpoint: reports_endpoint, timeout: @timeout, open_timeout: @open_timeout)
76
+ rescue ArgumentError => e
77
+ raise Error.new(e.message, exit_code: EXIT_CONFIG)
78
+ end
79
+
80
+ # Sends one JSON-RPC request to the MCP facade and returns its result.
81
+ def mcp(method, params = {})
82
+ require_key!(@api_key, "a dashboard API key")
83
+ @request_id += 1
84
+ body = JSON.generate(jsonrpc: "2.0", id: @request_id, method: method, params: params)
85
+ response = exchange(:post, URI(mcp_endpoint), @api_key, body: body)
86
+
87
+ case response.code
88
+ when "200" then nil
89
+ when "401" then raise refused("the dashboard refused the API key; create one under Settings -> API Keys", response, @api_key, 401)
90
+ when "404" then raise refused("nothing at #{mcp_endpoint} answers MCP; check the dashboard URL (the engine's mount)", response, @api_key, 404)
91
+ else raise refused("the dashboard's MCP endpoint answered HTTP #{response.code}", response, @api_key, response.code.to_i)
92
+ end
93
+
94
+ payload = parse_json(response.body)
95
+ if (error = payload["error"])
96
+ raise Error.new("#{method} failed: #{sanitize(error["message"], @api_key) || "error #{error["code"]}"}", status: error["code"])
97
+ end
98
+ payload["result"]
99
+ end
100
+
101
+ # Calls one of the dashboard's MCP tools and returns its structured
102
+ # result. A tool's own refusal (isError) raises with its message; a
103
+ # dashboard tool the facade does not list means the install turned
104
+ # them off (ActionAgent.mcp_dashboard_tools = false) or predates them.
105
+ def tool(name, arguments = {})
106
+ result = mcp("tools/call", name: name, arguments: arguments.compact)
107
+ structured = result.is_a?(Hash) ? result["structuredContent"] : nil
108
+ if result.is_a?(Hash) && result["isError"]
109
+ message = structured.is_a?(Hash) && structured["error"] || Array(result["content"]).filter_map { |part| part["text"] if part.is_a?(Hash) }.join(" ")
110
+ raise Error.new("#{name}: #{sanitize(message, @api_key) || "the tool refused the call"}")
111
+ end
112
+ structured.is_a?(Hash) ? structured : {}
113
+ rescue Error => e
114
+ raise unless e.status == JSONRPC_INVALID_PARAMS && e.message.include?("Unknown tool")
115
+
116
+ raise Error.new("This dashboard does not offer the #{name} tool over MCP: it predates the dashboard tools, or " \
117
+ "the install sets ActionAgent.mcp_dashboard_tools = false", exit_code: EXIT_UNAVAILABLE)
118
+ end
119
+
120
+ # Rails' own 404 body, from a dashboard whose routes have no such path.
121
+ GENERIC_NOT_FOUND = /\Anot found\z/i
122
+
123
+ # GETs +path+ under the mount with the ingest key and returns the
124
+ # response, raising on anything but 200. A 404 is one of two things:
125
+ # the endpoint's own refusal, a JSON error naming what is missing,
126
+ # raised as +missing+ with that detail; or nothing at the path at all
127
+ # (an older engine), raised as +unavailable+ with EXIT_UNAVAILABLE.
128
+ def get(path, accept:, query: nil, missing: nil, unavailable: nil)
129
+ require_key!(@ingest_key, "an ingest key")
130
+ uri = URI("#{url}#{path}")
131
+ uri.query = query if query
132
+ response = exchange(:get, uri, @ingest_key, accept: accept)
133
+ return response if response.code == "200"
134
+
135
+ case response.code
136
+ when "404"
137
+ detail = error_detail(response, @ingest_key)
138
+ raise Error.new("#{missing} (#{detail})", status: 404) if missing && detail && !detail.match?(GENERIC_NOT_FOUND)
139
+
140
+ raise Error.new(unavailable || "Nothing at #{path} answers; check the dashboard URL", status: 404, exit_code: EXIT_UNAVAILABLE)
141
+ when "401"
142
+ raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
143
+ else
144
+ raise refused("GET #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
145
+ end
146
+ end
147
+
148
+ # POSTs +body+, a JSON string, to +path+ under the mount with the
149
+ # ingest key and returns the response, raising on anything but a 2xx.
150
+ def post(path, body)
151
+ require_key!(@ingest_key, "an ingest key")
152
+ response = exchange(:post, URI("#{url}#{path}"), @ingest_key, body: body)
153
+ return response if response.code.start_with?("2")
154
+
155
+ if response.code == "401"
156
+ raise refused("the dashboard refused the ingest key; use the mount's ingest_api_key (--ingest-key)", response, @ingest_key, 401)
157
+ end
158
+ raise refused("POST #{path} answered HTTP #{response.code}", response, @ingest_key, response.code.to_i)
159
+ end
160
+
161
+ private
162
+
163
+ def require_key!(key, label)
164
+ return unless key.empty?
165
+
166
+ raise Error.new("No #{label}: run `activeagent login`, or set ACTIVEAGENT_API_KEY", exit_code: EXIT_CONFIG)
167
+ end
168
+
169
+ def exchange(verb, uri, key, body: nil, accept: "application/json")
170
+ http = Net::HTTP.new(uri.hostname, uri.port)
171
+ http.use_ssl = uri.scheme == "https"
172
+ http.open_timeout = @open_timeout
173
+ http.read_timeout = @timeout
174
+ http.write_timeout = @timeout
175
+ request = (verb == :post ? Net::HTTP::Post : Net::HTTP::Get).new(uri.request_uri)
176
+ request["Authorization"] = "Bearer #{key}"
177
+ request["Accept"] = accept
178
+ request["User-Agent"] = "activeagent-cli/#{ActiveAgent::CLI.version}"
179
+ if body
180
+ request["Content-Type"] = "application/json"
181
+ request.body = body
182
+ end
183
+ http.request(request)
184
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::Error => e
185
+ # These messages can quote the response's status line, headers or body.
186
+ raise Error.new("The dashboard returned a malformed response (#{e.class})"), cause: nil
187
+ rescue IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => e
188
+ raise Error.new("Could not reach #{uri.scheme}://#{uri.host} (#{e.class}); check the URL and that the dashboard is up")
189
+ end
190
+
191
+ def parse_json(body)
192
+ parsed = JSON.parse(body.to_s)
193
+ raise Error, "The dashboard returned JSON that is not an object" unless parsed.is_a?(Hash)
194
+
195
+ parsed
196
+ rescue JSON::ParserError
197
+ # The parser's message quotes the body.
198
+ raise Error.new("The dashboard returned invalid JSON"), cause: nil
199
+ end
200
+
201
+ def refused(message, response, key, status, exit_code: EXIT_FAILURE)
202
+ detail = error_detail(response, key)
203
+ Error.new(detail ? "#{message} (#{detail})" : message, status: status, exit_code: exit_code)
204
+ end
205
+
206
+ # The sanitized `error` string of a JSON error body, or nil.
207
+ def error_detail(response, key)
208
+ parsed = JSON.parse(response.body.to_s)
209
+ sanitize(parsed["error"], key) if parsed.is_a?(Hash)
210
+ rescue JSON::ParserError, EncodingError
211
+ nil
212
+ end
213
+
214
+ # Control characters and runs of whitespace become one space, the keys
215
+ # become [FILTERED], and the result is cut to DETAIL_LIMIT characters.
216
+ def sanitize(message, key)
217
+ return unless message.is_a?(String)
218
+
219
+ detail = message.scrub.gsub(/[[:space:]\p{C}]+/, " ").strip
220
+ [ key, @api_key, @ingest_key ].uniq.reject(&:empty?).each { |secret| detail = detail.gsub(secret, "[FILTERED]") }
221
+ return if detail.empty?
222
+
223
+ detail.length > DETAIL_LIMIT ? "#{detail[0, DETAIL_LIMIT - 1].rstrip}…" : detail
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module ActiveAgent
7
+ class CLI
8
+ # Named dashboard profiles in ~/.config/activeagent/credentials.yml
9
+ # ($XDG_CONFIG_HOME when set), written 0600 inside a 0700 directory:
10
+ #
11
+ # default:
12
+ # url: https://ops.example.com/activeagents
13
+ # api_key: aa_...
14
+ # ingest_key: ... # only when it differs from api_key
15
+ #
16
+ # A profile's values are what +login+ stored; the environment
17
+ # (ACTIVEAGENT_URL, ACTIVEAGENT_API_KEY, ACTIVEAGENT_INGEST_KEY) overrides
18
+ # them in CLI#profile, so CI never needs the file.
19
+ class Credentials
20
+ FIELDS = %w[url api_key ingest_key].freeze
21
+
22
+ attr_reader :path
23
+
24
+ def self.default_path(env = ENV)
25
+ base = env["XDG_CONFIG_HOME"].to_s.empty? ? File.join(Dir.home, ".config") : env["XDG_CONFIG_HOME"]
26
+ File.join(base, "activeagent", "credentials.yml")
27
+ end
28
+
29
+ def initialize(path)
30
+ @path = path
31
+ end
32
+
33
+ # Returns the profile's stored values, or an empty hash when the file or
34
+ # the profile does not exist.
35
+ def [](name)
36
+ profiles.fetch(name.to_s, {})
37
+ end
38
+
39
+ def names
40
+ profiles.keys
41
+ end
42
+
43
+ def save(name, values)
44
+ data = profiles.merge(name.to_s => values.transform_keys(&:to_s).slice(*FIELDS).compact)
45
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
46
+ # Written beside the target and renamed over it, so a reader never
47
+ # sees half a file, and created 0600 so the key is never readable by
48
+ # others, even for a moment.
49
+ temp = "#{path}.#{Process.pid}.tmp"
50
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(YAML.dump(data)) }
51
+ File.chmod(0o600, temp)
52
+ File.rename(temp, path)
53
+ ensure
54
+ FileUtils.rm_f(temp) if temp && File.exist?(temp)
55
+ end
56
+
57
+ def delete(name)
58
+ data = profiles
59
+ return false unless data.delete(name.to_s)
60
+
61
+ data.empty? ? File.delete(path) : File.open(path, "w", 0o600) { |file| file.write(YAML.dump(data)) }
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def profiles
68
+ return {} unless File.exist?(path)
69
+
70
+ data = YAML.safe_load_file(path) || {}
71
+ raise Error.new("#{path} is not a profile map; fix or remove it", exit_code: EXIT_CONFIG) unless data.is_a?(Hash)
72
+
73
+ data.select { |_, values| values.is_a?(Hash) }
74
+ rescue Psych::Exception => e
75
+ raise Error.new("#{path} is not valid YAML (#{e.class}); fix or remove it", exit_code: EXIT_CONFIG)
76
+ end
77
+ end
78
+ end
79
+ end