localvault 1.7.0 → 1.8.1

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: 2ff1cbda409eb9a922e301dfb73866fde96fa06dc7ace9e670df15ef587dd336
4
- data.tar.gz: 8f1b78e9c49e3f6f99a37cd97bbca36054e75c15d99d126699beb61f00a07840
3
+ metadata.gz: a7cdca7447f0efeb125773a3385a6e3beee17cb03d4c05e04b91dc59254215dd
4
+ data.tar.gz: b3378c85ad59e00561d5eb5f590e203469d86f8c88925e7b547721717175f5a4
5
5
  SHA512:
6
- metadata.gz: 35b625b3e4bd845ba5b5832016f4fd3f310b410e763c476a569c32920f8707317b007f00be9cbd441f704aeccf126d8c4d48646706cab7cc597d1e7fabc07d58
7
- data.tar.gz: b509a7068ba88ea63e30ecb9c036c1b3b2d63d58da73cd0e2a812069f907ad8439d0ee1ae5d10d17ca7fb97595c0c420c02ab5f9fe151c96525f0c07b85c91d6
6
+ metadata.gz: 2e4409c50f9aa0350b86541e905703ed24520cd583498d49de11a320cda8b6c2ebf86bc14c377bed52cd83438ba2254b0e243d91ffcf5521f7f84d9445072e1c
7
+ data.tar.gz: 97ff4555610dc166e82d01b6f11200ce516d0d36acb701625519c73b6e2f31d9096ae53e19d863128246d240d54eb82ebbd6f60ed51309ef4a8ec45675b144fd
data/README.md CHANGED
@@ -41,10 +41,10 @@ sudo dnf install libsodium-devel
41
41
  # Create a vault (prompts for passphrase)
42
42
  localvault init
43
43
 
44
- # Store secrets
45
- localvault set OPENAI_API_KEY "sk-proj-..."
46
- localvault set STRIPE_SECRET_KEY "sk_live_..."
47
- localvault set DATABASE_URL "postgres://localhost/myapp"
44
+ # Store secrets without putting values in shell history / process args
45
+ printf '%s' "$OPENAI_API_KEY" | localvault set OPENAI_API_KEY --stdin
46
+ printf '%s' "$STRIPE_SECRET_KEY" | localvault set STRIPE_SECRET_KEY --stdin
47
+ printf '%s' "$DATABASE_URL" | localvault set DATABASE_URL --stdin
48
48
 
49
49
  # Retrieve a secret (raw, pipeable)
50
50
  localvault get OPENAI_API_KEY
@@ -69,11 +69,15 @@ localvault exec -- rails server
69
69
  | Command | Description |
70
70
  |---------|-------------|
71
71
  | `init [NAME]` | Create a vault (Argon2id key derivation) |
72
- | `set KEY VALUE` | Store a secret (supports dot-notation: `project.KEY`) |
72
+ | `set KEY --stdin` | Store a secret from stdin without argv/history exposure |
73
+ | `set KEY [VALUE]` | Store a secret (positional value kept for compatibility) |
74
+ | `set --group GROUP KEY --stdin` | Store a secret in a named group from stdin |
73
75
  | `get KEY` | Retrieve a secret (raw, pipeable) |
74
76
  | `show` | Display all secrets in a table (masked by default) |
75
77
  | `show --reveal` | Display with values visible |
76
78
  | `show --group` | Group by dot-notation prefix (one table per project) |
79
+ | `show --group QUERY` | Show one exact or uniquely matching group |
80
+ | `groups [QUERY]` | List/search group names and key counts without values |
77
81
  | `list` | List key names only |
78
82
  | `delete KEY` | Remove a secret |
79
83
  | `rename OLD NEW` | Rename a secret key |
@@ -140,6 +144,7 @@ backward compatibility but the top-level forms are preferred.
140
144
  |---------|-------------|
141
145
  | `install-mcp [CLIENT]` | Configure MCP server in claude-code, cursor, or windsurf |
142
146
  | `mcp` | Start MCP server (stdio transport) |
147
+ | `doctor` | Check install and PATH readiness, including brew/asdf shadowing |
143
148
 
144
149
  All commands accept `--vault NAME` (or `-v NAME`) to target a specific vault. Default vault is `default`.
145
150
 
@@ -214,7 +219,9 @@ localvault remove @alice -v production --rotate
214
219
 
215
220
  ## MCP Server (AI Agents)
216
221
 
217
- Give AI agents controlled secret access without hardcoding credentials in MCP config. Exact `get_secret` calls can return secret values to the agent; fuzzy reads return candidate names only.
222
+ Give AI agents controlled secret access without hardcoding credentials in MCP
223
+ config. The default workflow keeps values out of model context: discover names,
224
+ build a `localvault exec` command, then run it with process-scoped injection.
218
225
 
219
226
  ```bash
220
227
  # One-command install for Claude Code
@@ -224,15 +231,27 @@ localvault install-mcp claude-code
224
231
  # Unlock your vault for the session
225
232
  localvault unlock
226
233
 
234
+ # Verify setup without starting the blocking stdio server
235
+ localvault mcp --check
236
+
237
+ # Diagnose brew/asdf PATH shadowing after upgrades
238
+ localvault doctor
239
+
227
240
  # MCP tools available to the agent:
228
241
  # localvault_whoami — diagnose active vault/session state
229
- # get_secret(key, vault?) — read an exact secret key
230
242
  # list_secrets(vault?, prefix?, query?) — list/search key names
243
+ # localvault_build_exec(command, ...) — build safe injection (does not execute)
244
+ # get_secret(key, allow_plaintext: true, vault?) — explicit plaintext reveal
231
245
  # set_secret(key, value, vault?) — store a secret
232
246
  # delete_secret(key, vault?) — remove a secret
233
247
  ```
234
248
 
235
- Command injection stays in the CLI. Use selectors, mappings, and profiles to keep subprocess envs scoped:
249
+ Agents should prefer `localvault_build_exec` for commands, evaluation, API calls,
250
+ and configuration checks. It returns both argv and a shell-safe command without
251
+ opening a vault or reading a value. `get_secret` rejects calls unless
252
+ `allow_plaintext: true` is explicit.
253
+
254
+ Use selectors, mappings, and profiles to keep subprocess envs scoped:
236
255
 
237
256
  ```bash
238
257
  localvault exec --profile aws -- aws sts get-caller-identity
@@ -249,9 +268,18 @@ One vault, many projects. Dot-notation keeps secrets organized:
249
268
  localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
250
269
  localvault set api.DATABASE_URL postgres://localhost/api -v work
251
270
 
271
+ # Or use the guided group form
272
+ localvault set --group myapp DATABASE_URL postgres://localhost/myapp -v work
273
+
274
+ # Search groups without revealing values
275
+ localvault groups app -v work
276
+
252
277
  # View grouped by project
253
278
  localvault show --group -v work
254
279
 
280
+ # Show one group by exact or unique prefix
281
+ localvault show --group my -v work
282
+
255
283
  # Filter to one project
256
284
  localvault show -p myapp -v work
257
285
 
data/bin/localvault CHANGED
@@ -9,4 +9,5 @@ rescue LoadError
9
9
  require "localvault/cli"
10
10
  end
11
11
 
12
- LocalVault::CLI.start(ARGV)
12
+ status = LocalVault::CLI.start(ARGV)
13
+ exit(status) if status.is_a?(Integer) && !status.zero?
@@ -0,0 +1,189 @@
1
+ require "did_you_mean"
2
+
3
+ module LocalVault
4
+ class CLI
5
+ class ErrorPresenter
6
+ Context = Data.define(:command_class, :namespace, :token, :command)
7
+
8
+ def initialize(root_class, argv)
9
+ @root_class = root_class
10
+ @argv = argv.map(&:to_s)
11
+ end
12
+
13
+ def render(error, io: $stderr)
14
+ @error = error
15
+ context = @context = resolve_context
16
+ io.puts error_heading(context)
17
+ io.puts "Usage: #{usage_for(context)}"
18
+ render_option_hint(context, io)
19
+ io.puts
20
+ io.puts "Try:"
21
+ suggestions_for(context).first(5).each { |suggestion| io.puts " #{suggestion}" }
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_context
27
+ command_class = @root_class
28
+ namespace = []
29
+ token = @argv.first
30
+ command = resolve_command(command_class, token)
31
+
32
+ if command && command_class.subcommand_classes.key?(command.name)
33
+ namespace << command.name
34
+ command_class = command_class.subcommand_classes.fetch(command.name)
35
+ token = @argv[1]
36
+ command = resolve_command(command_class, token)
37
+ end
38
+
39
+ Context.new(command_class, namespace, token, command)
40
+ end
41
+
42
+ def resolve_command(command_class, token)
43
+ return nil unless token && !token.start_with?("-")
44
+ return command_class.all_commands[token] if command_class.all_commands.key?(token)
45
+
46
+ matches = command_class.all_commands.keys.select { |name| name.start_with?(token) }
47
+ matches.one? ? command_class.all_commands[matches.first] : nil
48
+ end
49
+
50
+ def error_heading(context)
51
+ if @error.is_a?(CLI::GroupSelectionError)
52
+ if @error.kind == :ambiguous
53
+ "Error: Multiple groups match `#{@error.query}`."
54
+ else
55
+ "Error: No group matches `#{@error.query}`."
56
+ end
57
+ elsif @error.is_a?(CLI::GroupSaveError)
58
+ case @error.kind
59
+ when :collision then "Error: cannot create group: that name is already used by a secret key."
60
+ when :ambiguous then "Error: group name is ambiguous. Existing groups: #{@error.candidates.join(", ")}."
61
+ else "Error: group and key names may contain letters, digits, and underscores only."
62
+ end
63
+ elsif @error.is_a?(CLI::SetValueSourceError) && @error.kind == :multiple
64
+ "Error: #{@error.message}"
65
+ elsif group_save_attempt?
66
+ "Error: saving in a group needs GROUP, KEY, and VALUE."
67
+ elsif @error.is_a?(CLI::SetValueSourceError)
68
+ "Error: #{@error.message}"
69
+ elsif unknown_option
70
+ "Error: unknown option `#{unknown_option}`."
71
+ elsif context.command.nil?
72
+ "Error: unknown or ambiguous command."
73
+ else
74
+ "Error: this command needs a different combination of arguments."
75
+ end
76
+ end
77
+
78
+ def usage_for(context)
79
+ if @error.is_a?(CLI::GroupSelectionError)
80
+ "localvault show --group GROUP"
81
+ elsif group_save_attempt?
82
+ "localvault set --group GROUP KEY VALUE"
83
+ elsif context.command
84
+ ["localvault", *context.namespace, context.command.usage].join(" ")
85
+ else
86
+ ["localvault", *context.namespace, "COMMAND"].join(" ")
87
+ end
88
+ end
89
+
90
+ def render_option_hint(context, io)
91
+ return unless unknown_option
92
+
93
+ correction = DidYouMean::SpellChecker.new(dictionary: option_names(context)).correct(unknown_option).first
94
+ io.puts "\nDid you mean `#{correction}`?" if correction
95
+ end
96
+
97
+ def suggestions_for(context)
98
+ if @error.is_a?(CLI::GroupSelectionError)
99
+ return @error.candidates.map { |name| "localvault show --group #{name}" } if @error.kind == :ambiguous
100
+ return ["localvault groups #{@error.query}", "localvault groups"]
101
+ end
102
+
103
+ return group_save_suggestions if group_save_attempt?
104
+ return set_suggestions if context.command&.name == "set"
105
+ return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
106
+ return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
107
+
108
+ if context.command
109
+ examples = curated_examples(context.command)
110
+ examples.empty? ? [usage_for(context)] : examples
111
+ else
112
+ command_suggestions(context)
113
+ end
114
+ end
115
+
116
+ def group_save_suggestions
117
+ [
118
+ "localvault set --group GROUP KEY VALUE",
119
+ "localvault set GROUP.KEY VALUE",
120
+ "localvault groups [QUERY]"
121
+ ]
122
+ end
123
+
124
+ def set_suggestions
125
+ [
126
+ %(printf '%s' "$SECRET" | localvault set KEY --stdin),
127
+ "localvault set KEY VALUE",
128
+ *group_save_suggestions
129
+ ]
130
+ end
131
+
132
+ def show_group_suggestions
133
+ [
134
+ "localvault show --group GROUP",
135
+ "localvault groups [QUERY]",
136
+ "localvault show --project PROJECT"
137
+ ]
138
+ end
139
+
140
+ def command_suggestions(context)
141
+ names = context.command_class.all_commands.keys.reject { |name| name == "help" }
142
+ matches = prefix_or_spelling_matches(names, context.token.to_s)
143
+ items = matches.map do |name|
144
+ command = context.command_class.all_commands.fetch(name)
145
+ ["localvault", *context.namespace, command.usage, " # #{command.description}"].join(" ")
146
+ end
147
+ items.empty? ? ["localvault help"] : items
148
+ end
149
+
150
+ def prefix_or_spelling_matches(names, token)
151
+ prefix = names.select { |name| name.start_with?(token) }
152
+ return prefix.sort unless prefix.empty?
153
+
154
+ DidYouMean::SpellChecker.new(dictionary: names).correct(token).first(5)
155
+ end
156
+
157
+ def curated_examples(command)
158
+ command.long_description.to_s.lines.filter_map do |line|
159
+ example = line.delete("\u0005").strip
160
+ example if example.start_with?("localvault ")
161
+ end.uniq
162
+ end
163
+
164
+ def option_names(context)
165
+ options = context.command_class.class_options.values
166
+ options += context.command.options.values if context.command
167
+
168
+ options.flat_map do |option|
169
+ ["--#{option.name.to_s.tr("_", "-")}", *Array(option.aliases)]
170
+ end.uniq
171
+ end
172
+
173
+ def unknown_option
174
+ return @unknown_option if defined?(@unknown_option)
175
+
176
+ known = option_names(@context)
177
+ @unknown_option = @argv.find do |argument|
178
+ next false unless argument.start_with?("--") && argument != "--"
179
+
180
+ !known.include?(argument.split("=", 2).first)
181
+ end
182
+ end
183
+
184
+ def group_save_attempt?
185
+ @argv.first == "set" && @argv.include?("--group")
186
+ end
187
+ end
188
+ end
189
+ end