localvault 1.6.2 → 1.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3fe9cdf4fe4857588507532f6478318a7b02cdc47487bbecbe7ce54715e69e33
4
- data.tar.gz: 1be1af8d9476e1171895a0ba0677e206832de1386416cd5fb7e7060e7b8b85f5
3
+ metadata.gz: 86be5d86ef86c74bcfc777561088264d1b908eaca92a2b501d5e23d937e5a906
4
+ data.tar.gz: d3d079c65ff54ca198907957ae3193a3a73520aa84169cf0067bc254967fea05
5
5
  SHA512:
6
- metadata.gz: 500451ad18462b5380839eb06b031b83b3acb91872430855e07038896c392f9488934aaf1649bd311886890400aaef9c45a062d8503c6db0760a2927b56a1650
7
- data.tar.gz: aa1aa1b764c2de50ab0c8fb425b14a77e05085f2224131c7f53dc1eaaccc9b80732f160716a1826ef479c0d9653e5451074ca585927a86630ef68a92f23b9b8c
6
+ metadata.gz: f0d5b4b8d1575b8b91fbd742afd0aef2f71401e72882149cf0b83e68cd233636592c5e0af85512007b4cff604d466ff38d5ac085ea7eea60e9b9446a3d7247a5
7
+ data.tar.gz: 98a51ac12f4857e0f381fa4b4449a63687d402fe3bf3d97e693f75bfc526440efbc0fd9df14f397c99771c4c871c9a8f164b51e29cca56a232bfa78c099ca8c9
data/README.md CHANGED
@@ -70,10 +70,13 @@ localvault exec -- rails server
70
70
  |---------|-------------|
71
71
  | `init [NAME]` | Create a vault (Argon2id key derivation) |
72
72
  | `set KEY VALUE` | Store a secret (supports dot-notation: `project.KEY`) |
73
+ | `set --group GROUP KEY VALUE` | Store a secret in a named group |
73
74
  | `get KEY` | Retrieve a secret (raw, pipeable) |
74
75
  | `show` | Display all secrets in a table (masked by default) |
75
76
  | `show --reveal` | Display with values visible |
76
77
  | `show --group` | Group by dot-notation prefix (one table per project) |
78
+ | `show --group QUERY` | Show one exact or uniquely matching group |
79
+ | `groups [QUERY]` | List/search group names and key counts without values |
77
80
  | `list` | List key names only |
78
81
  | `delete KEY` | Remove a secret |
79
82
  | `rename OLD NEW` | Rename a secret key |
@@ -214,28 +217,41 @@ localvault remove @alice -v production --rotate
214
217
 
215
218
  ## MCP Server (AI Agents)
216
219
 
217
- Give AI agents safe secret access. Keys never appear in agent context or config files.
220
+ Give AI agents controlled secret access without hardcoding credentials in MCP
221
+ config. The default workflow keeps values out of model context: discover names,
222
+ build a `localvault exec` command, then run it with process-scoped injection.
218
223
 
219
224
  ```bash
220
225
  # One-command install for Claude Code
221
226
  localvault install-mcp claude-code
222
- # Also supports: cursor, windsurf, zed
227
+ # Also supports: cursor, windsurf
223
228
 
224
229
  # Unlock your vault for the session
225
230
  localvault unlock
226
231
 
232
+ # Verify setup without starting the blocking stdio server
233
+ localvault mcp --check
234
+
227
235
  # MCP tools available to the agent:
228
- # get_secret(key, vault?) read a secret
229
- # list_secrets(vault?, prefix?) — list key names
236
+ # localvault_whoami diagnose active vault/session state
237
+ # list_secrets(vault?, prefix?, query?) — list/search key names
238
+ # localvault_build_exec(command, ...) — build safe injection (does not execute)
239
+ # get_secret(key, allow_plaintext: true, vault?) — explicit plaintext reveal
230
240
  # set_secret(key, value, vault?) — store a secret
231
- # delete_secret(key, vault?) — remove a secret
241
+ # delete_secret(key, vault?) — remove a secret
232
242
  ```
233
243
 
234
- **exec_action** agent declares intent, LocalVault executes with secrets injected. The agent never sees the key:
244
+ Agents should prefer `localvault_build_exec` for commands, evaluation, API calls,
245
+ and configuration checks. It returns both argv and a shell-safe command without
246
+ opening a vault or reading a value. `get_secret` rejects calls unless
247
+ `allow_plaintext: true` is explicit.
248
+
249
+ Use selectors, mappings, and profiles to keep subprocess envs scoped:
235
250
 
236
251
  ```bash
237
- localvault exec_action -- curl -s https://api.openai.com/v1/models \
238
- -H "Authorization: Bearer $OPENAI_API_KEY"
252
+ localvault exec --profile aws -- aws sts get-caller-identity
253
+ localvault exec --only AWS_IAM.*,AWS_SES.* --except AWS_SES.smtp_password -- your-script
254
+ localvault env --map AWS_IAM.access_key_id=AWS_ACCESS_KEY_ID
239
255
  ```
240
256
 
241
257
  ## Multi-Project Vaults
@@ -247,15 +263,28 @@ One vault, many projects. Dot-notation keeps secrets organized:
247
263
  localvault set myapp.DATABASE_URL postgres://localhost/myapp -v work
248
264
  localvault set api.DATABASE_URL postgres://localhost/api -v work
249
265
 
266
+ # Or use the guided group form
267
+ localvault set --group myapp DATABASE_URL postgres://localhost/myapp -v work
268
+
269
+ # Search groups without revealing values
270
+ localvault groups app -v work
271
+
250
272
  # View grouped by project
251
273
  localvault show --group -v work
252
274
 
275
+ # Show one group by exact or unique prefix
276
+ localvault show --group my -v work
277
+
253
278
  # Filter to one project
254
279
  localvault show -p myapp -v work
255
280
 
256
281
  # Export one project
257
282
  eval $(localvault env -p myapp -v work)
258
283
 
284
+ # Export all projects with project.key transformed to PROJECT__key
285
+ localvault env -v work
286
+ # → MYAPP__DATABASE_URL, API__DATABASE_URL
287
+
259
288
  # Bulk import
260
289
  localvault import .env --prefix myapp -v work
261
290
  ```
@@ -272,7 +301,7 @@ localvault get API_KEY
272
301
  localvault exec -- rails server
273
302
  ```
274
303
 
275
- Session lives in `LOCALVAULT_SESSION` disappears when the terminal closes.
304
+ Unlocking writes a derived key to `LOCALVAULT_SESSION` and also caches it with an 8-hour TTL in Keychain or LocalVault's file fallback so MCP and new terminals can reuse it until `localvault lock`.
276
305
 
277
306
  ## Security
278
307
 
@@ -324,7 +353,7 @@ localvault login --server https://vaulthost.example
324
353
  git clone https://github.com/inventlist/localvault.git
325
354
  cd localvault
326
355
  bundle install
327
- bundle exec rake test # 463 tests, 918 assertions
356
+ bundle exec rake test
328
357
  ```
329
358
 
330
359
  ## Used by
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,177 @@
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 group_save_attempt?
64
+ "Error: saving in a group needs GROUP, KEY, and VALUE."
65
+ elsif unknown_option
66
+ "Error: unknown option `#{unknown_option}`."
67
+ elsif context.command.nil?
68
+ "Error: unknown or ambiguous command."
69
+ else
70
+ "Error: this command needs a different combination of arguments."
71
+ end
72
+ end
73
+
74
+ def usage_for(context)
75
+ if @error.is_a?(CLI::GroupSelectionError)
76
+ "localvault show --group GROUP"
77
+ elsif group_save_attempt?
78
+ "localvault set --group GROUP KEY VALUE"
79
+ elsif context.command
80
+ ["localvault", *context.namespace, context.command.usage].join(" ")
81
+ else
82
+ ["localvault", *context.namespace, "COMMAND"].join(" ")
83
+ end
84
+ end
85
+
86
+ def render_option_hint(context, io)
87
+ return unless unknown_option
88
+
89
+ correction = DidYouMean::SpellChecker.new(dictionary: option_names(context)).correct(unknown_option).first
90
+ io.puts "\nDid you mean `#{correction}`?" if correction
91
+ end
92
+
93
+ def suggestions_for(context)
94
+ if @error.is_a?(CLI::GroupSelectionError)
95
+ return @error.candidates.map { |name| "localvault show --group #{name}" } if @error.kind == :ambiguous
96
+ return ["localvault groups #{@error.query}", "localvault groups"]
97
+ end
98
+
99
+ return group_save_suggestions if group_save_attempt?
100
+ return ["localvault set KEY VALUE", *group_save_suggestions] if context.command&.name == "set"
101
+ return show_group_suggestions if context.command&.name == "show" || unknown_option == "--group-by"
102
+ return ["localvault add HANDLE", "localvault team add HANDLE"] if context.namespace == ["team"] && context.command&.name == "add"
103
+
104
+ if context.command
105
+ examples = curated_examples(context.command)
106
+ examples.empty? ? [usage_for(context)] : examples
107
+ else
108
+ command_suggestions(context)
109
+ end
110
+ end
111
+
112
+ def group_save_suggestions
113
+ [
114
+ "localvault set --group GROUP KEY VALUE",
115
+ "localvault set GROUP.KEY VALUE",
116
+ "localvault groups [QUERY]"
117
+ ]
118
+ end
119
+
120
+ def show_group_suggestions
121
+ [
122
+ "localvault show --group GROUP",
123
+ "localvault groups [QUERY]",
124
+ "localvault show --project PROJECT"
125
+ ]
126
+ end
127
+
128
+ def command_suggestions(context)
129
+ names = context.command_class.all_commands.keys.reject { |name| name == "help" }
130
+ matches = prefix_or_spelling_matches(names, context.token.to_s)
131
+ items = matches.map do |name|
132
+ command = context.command_class.all_commands.fetch(name)
133
+ ["localvault", *context.namespace, command.usage, " # #{command.description}"].join(" ")
134
+ end
135
+ items.empty? ? ["localvault help"] : items
136
+ end
137
+
138
+ def prefix_or_spelling_matches(names, token)
139
+ prefix = names.select { |name| name.start_with?(token) }
140
+ return prefix.sort unless prefix.empty?
141
+
142
+ DidYouMean::SpellChecker.new(dictionary: names).correct(token).first(5)
143
+ end
144
+
145
+ def curated_examples(command)
146
+ command.long_description.to_s.lines.filter_map do |line|
147
+ example = line.delete("\u0005").strip
148
+ example if example.start_with?("localvault ")
149
+ end.uniq
150
+ end
151
+
152
+ def option_names(context)
153
+ options = context.command_class.class_options.values
154
+ options += context.command.options.values if context.command
155
+
156
+ options.flat_map do |option|
157
+ ["--#{option.name.to_s.tr("_", "-")}", *Array(option.aliases)]
158
+ end.uniq
159
+ end
160
+
161
+ def unknown_option
162
+ return @unknown_option if defined?(@unknown_option)
163
+
164
+ known = option_names(@context)
165
+ @unknown_option = @argv.find do |argument|
166
+ next false unless argument.start_with?("--") && argument != "--"
167
+
168
+ !known.include?(argument.split("=", 2).first)
169
+ end
170
+ end
171
+
172
+ def group_save_attempt?
173
+ @argv.first == "set" && @argv.include?("--group")
174
+ end
175
+ end
176
+ end
177
+ end