Class: Rubino::CLI::ConfigCommand

Inherits:
Thor
  • Object
show all
Defined in:
lib/rubino/cli/config_command.rb

Overview

Subcommands for managing configuration

Constant Summary collapse

ALIASES =

Discoverability aliases (#36-follow-up): /status advertises settings by the short label a user then types into ‘/config <key>`, but the real config key is nested — so `/config reasoning` reported “not found” even though /status shows “reasoning:” and `/reasoning` is a command. Map the advertised short names (and the names of the commands that WRITE them) to their dotted config paths so get/set resolve the same key /status shows. Dotted keys are unaffected (a user can still pass `display.reasoning`).

{
  "reasoning" => "display.reasoning",
  "effort" => "thinking.effort",
  "think" => "thinking.effort"
}.freeze
GET_FALLBACKS =

GET-only fallbacks (#66): a resolved key whose value, when unset, still lives under a legacy sibling. ‘/reasoning` and `/status` use display.reasoning (the SET target stays canonical), but a config carrying only the documented legacy display.show_reasoning boolean would otherwise report “not found” on `/config reasoning`. Read through to the legacy key so a value set either way resolves; set never touches the legacy key.

{
  "display.reasoning" => "display.show_reasoning"
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.dig_config(path) ⇒ Object

Effective-config read for a dotted path. A scalar intermediate node (descending into a String) has no #dig; treat such a path as unset rather than crashing.



98
99
100
101
102
# File 'lib/rubino/cli/config_command.rb', line 98

def self.dig_config(path)
  Rubino.configuration.dig(*path)
rescue TypeError
  nil
end

.exit_on_failure?Boolean

Returns:

  • (Boolean)


13
14
15
# File 'lib/rubino/cli/config_command.rb', line 13

def self.exit_on_failure?
  true
end

.from_defaults?(path) ⇒ Boolean

True when path has no value in the user’s config.yml as written on disk (so the merged value is coming from the built-in defaults). Best-effort: any read hiccup reports false (no annotation) rather than a false “(default)”. A nil at the path in the raw file counts as “not set”.

Returns:

  • (Boolean)


108
109
110
111
112
113
114
115
116
# File 'lib/rubino/cli/config_command.rb', line 108

def self.from_defaults?(path)
  raw =
    begin
      Config::Loader.new.raw_config
    rescue StandardError
      {}
    end
  raw.is_a?(Hash) && raw.dig(*path).nil?
end

.redact(value, key: nil) ⇒ Object

Deep DISPLAY masking for config values (#187): a secret-named key’s value renders as *** (Util::SecretsMask — the same heuristic approval prompts use), hashes/arrays are walked, and plain strings are scanned for inline ‘Bearer …`-style credentials. Display-only — the file and the live configuration keep the real values. Empty/nil values pass through unmasked so a *** never fakes a value that isn’t set.



171
172
173
174
175
176
177
178
179
# File 'lib/rubino/cli/config_command.rb', line 171

def self.redact(value, key: nil)
  case value
  when Hash  then value.to_h { |k, v| [k, redact(v, key: k)] }
  when Array then value.map { |v| redact(v, key: key) }
  when String
    value.empty? ? value : Util::SecretsMask.mask_value(value, key: key)
  else value
  end
end

.render_get(key, ui:) ⇒ Object

ONE get rendering for both surfaces (#187): this CLI verb and the in-chat ‘/config get` (Commands::Executor). Resolves against the effective config (file merged over defaults), the same source `show` and the running agent use, so default-valued keys are returned instead of falsely reported “not found” (issue #36). A scalar intermediate node (e.g. descending into a String) has no #dig; treat such a path as “not found” rather than crashing. Secret-named keys render masked.

Returns true when the key resolved, false when not found, so the CLI verb can exit non-zero on a miss (P2-H1) while the REPL surface ignores the return. The not-found NOTICE is left to each caller: the CLI verb raises a Thor::Error (stderr + non-zero), the in-chat handler shows the stdout warning below — so a miss never double-prints. rubocop:disable Naming/PredicateMethod – it RENDERS (a side effect) and returns found?; it isn’t a pure predicate, and the name is the documented shared-renderer seam (#187) referenced by the in-chat handler.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/rubino/cli/config_command.rb', line 74

def self.render_get(key, ui:)
  key  = ALIASES.fetch(key, key)
  path = key.split(".")
  value = dig_config(path)
  if value.nil? && (legacy = GET_FALLBACKS[key])
    path  = legacy.split(".")
    value = dig_config(path)
    key   = legacy unless value.nil?
  end
  return false if value.nil?

  # F4: annotate a value that comes from the built-in DEFAULTS rather than
  # the user's config.yml, so "I unset it but `config get` still shows a
  # value" reads correctly — the default is what's in effect, not a stale
  # setting. A key whose resolved value is NOT present in the raw (un-merged)
  # user file is default-sourced.
  suffix = from_defaults?(path) ? " (default)" : ""
  ui.info("#{key} = #{redact(value, key: path.last)}#{suffix}")
  true
end

.render_show(ui:) ⇒ Object

ONE full-config rendering for both surfaces (#187): this CLI verb and the in-chat ‘/config show` — with secret-named keys masked, which the clear-text dump never did (api_key landed verbatim in the scrollback).



161
162
163
# File 'lib/rubino/cli/config_command.rb', line 161

def self.render_show(ui:)
  ui.info(redact(Rubino.configuration.raw).to_yaml)
end

Instance Method Details

#get(key) ⇒ Object

Raises:

  • (Thor::Error)


48
49
50
51
52
53
54
55
56
# File 'lib/rubino/cli/config_command.rb', line 48

def get(key)
  # A missing key is a FAILURE on the automation surface (P2-H1/H2): when
  # render_get reports not-found, raise Thor::Error so exit_on_failure?
  # exits non-zero with the message on stderr (the shared renderer's
  # ui.warning went to stdout and returned 0). The in-chat `/config get`
  # surface ignores the return value, so its REPL-friendly warning stays.
  found = self.class.render_get(key, ui: Rubino.ui)
  raise Thor::Error, "config key not found: #{key}" unless found
end

#pathObject



182
183
184
# File 'lib/rubino/cli/config_command.rb', line 182

def path
  Rubino.ui.info(config_path)
end

#set(key, value) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/rubino/cli/config_command.rb', line 120

def set(key, value)
  key    = ALIASES.fetch(key, key)
  writer = Config::Writer.new(config_path: config_path)
  writer.set(key, value)
  # Mask a secret-named value the SAME way `config get`/`show` do (#187):
  # a successful SET must not echo a raw api_key/token into the scrollback.
  Rubino.ui.success("#{key} = #{self.class.redact(value, key: key.split(".").last)}")
rescue ConfigurationError => e
  # A validation failure is a FAILURE on the automation surface: route the
  # ✗ line to STDERR (it used to print on stdout), keeping exit 1. For an
  # array-typed key, append the accepted syntax — Writer.coerce_array only
  # accepts an explicit JSON array literal — so the user isn't left
  # guessing how to pass multiple values (e.g. `"git log"` was rejected).
  warn "#{e.message}"
  warn array_syntax_hint(key) if e.message.include?("expected array")
  exit(1)
end

#showObject



154
155
156
# File 'lib/rubino/cli/config_command.rb', line 154

def show
  self.class.render_show(ui: Rubino.ui)
end

#unset(key) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/rubino/cli/config_command.rb', line 139

def unset(key)
  writer = Config::Writer.new(config_path: config_path)
  if writer.unset(key)
    Rubino.ui.success("unset #{key}")
  else
    # Not present is a no-op, not a failure: exit 0 with a clear notice so
    # `config unset` is idempotent (re-running it never errors).
    Rubino.ui.info("#{key} was not set (nothing to remove)")
  end
rescue ConfigurationError => e
  Rubino.ui.error(e.message)
  exit(1)
end