localvault 0.9.6
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +227 -0
- data/bin/localvault +12 -0
- data/lib/localvault/api_client.rb +125 -0
- data/lib/localvault/cli/keys.rb +56 -0
- data/lib/localvault/cli/team.rb +37 -0
- data/lib/localvault/cli.rb +1073 -0
- data/lib/localvault/config.rb +80 -0
- data/lib/localvault/crypto.rb +63 -0
- data/lib/localvault/identity.rb +44 -0
- data/lib/localvault/mcp/server.rb +158 -0
- data/lib/localvault/mcp/tools.rb +115 -0
- data/lib/localvault/session_cache.rb +107 -0
- data/lib/localvault/share_crypto.rb +48 -0
- data/lib/localvault/store.rb +109 -0
- data/lib/localvault/vault.rb +154 -0
- data/lib/localvault/version.rb +3 -0
- data/lib/localvault.rb +11 -0
- metadata +144 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require "io/console"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "lipgloss"
|
|
5
|
+
require_relative "session_cache"
|
|
6
|
+
|
|
7
|
+
module LocalVault
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
|
|
10
|
+
|
|
11
|
+
desc "init [NAME]", "Create a new vault"
|
|
12
|
+
def init(name = nil)
|
|
13
|
+
vault_name = name || Config.default_vault
|
|
14
|
+
passphrase = prompt_passphrase("Passphrase: ")
|
|
15
|
+
|
|
16
|
+
if passphrase.empty?
|
|
17
|
+
abort_with "Passphrase cannot be empty"
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
confirm = prompt_passphrase("Confirm passphrase: ")
|
|
22
|
+
if passphrase != confirm
|
|
23
|
+
abort_with "Passphrases do not match"
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
salt = Crypto.generate_salt
|
|
28
|
+
master_key = Crypto.derive_master_key(passphrase, salt)
|
|
29
|
+
Vault.create!(name: vault_name, master_key: master_key, salt: salt)
|
|
30
|
+
$stdout.puts "Vault '#{vault_name}' created."
|
|
31
|
+
rescue RuntimeError => e
|
|
32
|
+
abort_with e.message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
desc "set KEY VALUE", "Store a secret (supports dot-notation for nested keys)"
|
|
36
|
+
long_desc <<~DESC
|
|
37
|
+
Store a secret in the current vault.
|
|
38
|
+
|
|
39
|
+
FLAT KEY (simple):
|
|
40
|
+
\x05 localvault set DATABASE_URL postgres://localhost/myapp
|
|
41
|
+
\x05 localvault set STRIPE_KEY sk_live_abc123
|
|
42
|
+
|
|
43
|
+
NESTED KEY (dot-notation for team/multi-project vaults):
|
|
44
|
+
\x05 localvault set platepose.DATABASE_URL postgres://... -v intellectaco
|
|
45
|
+
\x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
|
|
46
|
+
\x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
|
|
47
|
+
|
|
48
|
+
The dot separates project from key name. One vault can hold many projects.
|
|
49
|
+
Use `localvault show -p platepose -v vault` to view a single project.
|
|
50
|
+
Use `localvault import` to bulk-load from a .env, .json, or .yml file.
|
|
51
|
+
DESC
|
|
52
|
+
def set(key, value)
|
|
53
|
+
vault = open_vault!
|
|
54
|
+
vault.set(key, value)
|
|
55
|
+
$stdout.puts "Set #{key} in vault '#{vault.name}'"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "get KEY", "Retrieve a secret value by key"
|
|
59
|
+
long_desc <<~DESC
|
|
60
|
+
Print the value of a secret to stdout.
|
|
61
|
+
|
|
62
|
+
FLAT KEY:
|
|
63
|
+
\x05 localvault get DATABASE_URL
|
|
64
|
+
|
|
65
|
+
NESTED KEY (dot-notation):
|
|
66
|
+
\x05 localvault get platepose.DATABASE_URL -v intellectaco
|
|
67
|
+
\x05 localvault get platepose.SECRET_KEY_BASE -v intellectaco
|
|
68
|
+
|
|
69
|
+
Output is the raw value — safe to use in scripts:
|
|
70
|
+
\x05 export DB=$(localvault get platepose.DATABASE_URL -v intellectaco)
|
|
71
|
+
DESC
|
|
72
|
+
def get(key)
|
|
73
|
+
vault = open_vault!
|
|
74
|
+
value = vault.get(key)
|
|
75
|
+
if value.nil?
|
|
76
|
+
abort_with "Key '#{key}' not found in vault '#{vault.name}'"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
$stdout.puts value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
desc "list", "List all secret keys in the vault"
|
|
83
|
+
long_desc <<~DESC
|
|
84
|
+
Print all secret keys, one per line. Nested keys use dot-notation.
|
|
85
|
+
|
|
86
|
+
\x05 localvault list
|
|
87
|
+
\x05 localvault list -v intellectaco
|
|
88
|
+
|
|
89
|
+
Example output for a team vault:
|
|
90
|
+
\x05 platepose.DATABASE_URL
|
|
91
|
+
\x05 platepose.SECRET_KEY_BASE
|
|
92
|
+
\x05 platepose.RAILS_MASTER_KEY
|
|
93
|
+
\x05 inventlist.DATABASE_URL
|
|
94
|
+
\x05 inventlist.STRIPE_KEY
|
|
95
|
+
|
|
96
|
+
Use `localvault show` for a formatted table, or `localvault show -p PROJECT`
|
|
97
|
+
to filter to a single project.
|
|
98
|
+
DESC
|
|
99
|
+
def list
|
|
100
|
+
vault = open_vault!
|
|
101
|
+
vault.list.each { |key| $stdout.puts key }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
desc "delete KEY", "Remove a secret or entire project group"
|
|
105
|
+
long_desc <<~DESC
|
|
106
|
+
Delete a single key or an entire project group.
|
|
107
|
+
|
|
108
|
+
DELETE ONE KEY:
|
|
109
|
+
\x05 localvault delete STRIPE_KEY
|
|
110
|
+
\x05 localvault delete platepose.DATABASE_URL -v intellectaco
|
|
111
|
+
|
|
112
|
+
DELETE AN ENTIRE PROJECT GROUP (removes all keys under project.*):
|
|
113
|
+
\x05 localvault delete platepose -v intellectaco
|
|
114
|
+
|
|
115
|
+
This is permanent — use `localvault show` to verify before deleting.
|
|
116
|
+
DESC
|
|
117
|
+
def delete(key)
|
|
118
|
+
vault = open_vault!
|
|
119
|
+
deleted = vault.delete(key)
|
|
120
|
+
if deleted.nil?
|
|
121
|
+
abort_with "Key '#{key}' not found in vault '#{vault.name}'"
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
$stdout.puts "Deleted #{key} from vault '#{vault.name}'"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
desc "env", "Export secrets as shell variable assignments"
|
|
128
|
+
long_desc <<~DESC
|
|
129
|
+
Print `export KEY=value` lines for use with eval or shell sourcing.
|
|
130
|
+
|
|
131
|
+
FLAT VAULT:
|
|
132
|
+
\x05 eval $(localvault env)
|
|
133
|
+
\x05 eval $(localvault env -v staging)
|
|
134
|
+
|
|
135
|
+
TEAM VAULT — one project (keys exported without prefix):
|
|
136
|
+
\x05 eval $(localvault env -p platepose -v intellectaco)
|
|
137
|
+
\x05 # → export DATABASE_URL=... export SECRET_KEY_BASE=...
|
|
138
|
+
|
|
139
|
+
TEAM VAULT — all projects (keys prefixed to avoid collisions):
|
|
140
|
+
\x05 eval $(localvault env -v intellectaco)
|
|
141
|
+
\x05 # → export PLATEPOSE__DATABASE_URL=... export INVENTLIST__DATABASE_URL=...
|
|
142
|
+
|
|
143
|
+
Use `localvault exec` to inject directly into a subprocess without eval.
|
|
144
|
+
DESC
|
|
145
|
+
method_option :project, aliases: "-p", type: :string, desc: "Export only this project group (no prefix)"
|
|
146
|
+
def env
|
|
147
|
+
vault = open_vault!
|
|
148
|
+
$stdout.puts vault.export_env(project: options[:project])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
desc "exec -- CMD", "Run a command with secrets injected as environment variables"
|
|
152
|
+
long_desc <<~DESC
|
|
153
|
+
Run any command with vault secrets in its environment. The `--` separator
|
|
154
|
+
is required to prevent localvault from consuming the command's own flags.
|
|
155
|
+
|
|
156
|
+
FLAT VAULT:
|
|
157
|
+
\x05 localvault exec -- rails server
|
|
158
|
+
\x05 localvault exec -- bundle exec rspec
|
|
159
|
+
|
|
160
|
+
TEAM VAULT — one project (keys injected without prefix):
|
|
161
|
+
\x05 localvault exec -p platepose -v intellectaco -- rails server
|
|
162
|
+
\x05 # → DATABASE_URL, SECRET_KEY_BASE, RAILS_MASTER_KEY in env
|
|
163
|
+
|
|
164
|
+
TEAM VAULT — all projects (keys prefixed to avoid collisions):
|
|
165
|
+
\x05 localvault exec -v intellectaco -- your-script
|
|
166
|
+
\x05 # → PLATEPOSE__DATABASE_URL, INVENTLIST__DATABASE_URL, etc.
|
|
167
|
+
DESC
|
|
168
|
+
method_option :project, aliases: "-p", type: :string, desc: "Inject only this project group (no prefix)"
|
|
169
|
+
def exec(*cmd)
|
|
170
|
+
vault = open_vault!
|
|
171
|
+
env_vars = vault.env_hash(project: options[:project])
|
|
172
|
+
Kernel.exec(env_vars, *cmd)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
desc "vaults", "List all vaults with secret counts"
|
|
176
|
+
def vaults
|
|
177
|
+
names = Store.list_vaults
|
|
178
|
+
if names.empty?
|
|
179
|
+
$stdout.puts "No vaults found. Run: localvault init"
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
default_name = Config.default_vault
|
|
184
|
+
rows = names.map do |name|
|
|
185
|
+
store = Store.new(name)
|
|
186
|
+
default_marker = name == default_name ? "✓" : ""
|
|
187
|
+
[name, store.count.to_s, default_marker]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
table = Lipgloss::Table.new
|
|
191
|
+
.headers(["Vault", "Secrets", "Default"])
|
|
192
|
+
.rows(rows)
|
|
193
|
+
.border(:rounded)
|
|
194
|
+
.style_func(rows: rows.size, columns: 3) do |row, _col|
|
|
195
|
+
if row == Lipgloss::Table::HEADER_ROW
|
|
196
|
+
HEADER_STYLE
|
|
197
|
+
else
|
|
198
|
+
row.odd? ? ODD_STYLE : EVEN_STYLE
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
.render
|
|
202
|
+
|
|
203
|
+
$stdout.puts table
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
desc "unlock", "Output session token for passphrase-free access"
|
|
207
|
+
def unlock
|
|
208
|
+
vault_name = resolve_vault_name
|
|
209
|
+
store = Store.new(vault_name)
|
|
210
|
+
unless store.exists?
|
|
211
|
+
abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
passphrase = prompt_passphrase("Passphrase: ")
|
|
216
|
+
master_key = Crypto.derive_master_key(passphrase, store.salt)
|
|
217
|
+
|
|
218
|
+
# Verify passphrase by attempting to decrypt
|
|
219
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
220
|
+
vault.all
|
|
221
|
+
|
|
222
|
+
SessionCache.set(vault_name, master_key)
|
|
223
|
+
token = Base64.strict_encode64("#{vault_name}:#{Base64.strict_encode64(master_key)}")
|
|
224
|
+
$stdout.puts "export LOCALVAULT_SESSION=\"#{token}\""
|
|
225
|
+
rescue Crypto::DecryptionError
|
|
226
|
+
abort_with "Wrong passphrase for vault '#{vault_name}'"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
desc "show", "Display secrets in a formatted table (masked by default)"
|
|
230
|
+
long_desc <<~DESC
|
|
231
|
+
Show secrets in the current vault. Values are masked by default.
|
|
232
|
+
Running this command also caches your passphrase in Keychain for 8 hours.
|
|
233
|
+
|
|
234
|
+
FLAT VAULT (simple keys):
|
|
235
|
+
\x05 localvault show
|
|
236
|
+
\x05 localvault show --reveal # show full values
|
|
237
|
+
\x05 localvault show --group # group by prefix: STRIPE_KEY, STRIPE_SECRET → STRIPE
|
|
238
|
+
|
|
239
|
+
TEAM VAULT (dot-notation — grouped automatically):
|
|
240
|
+
\x05 localvault show -v intellectaco # all projects
|
|
241
|
+
\x05 localvault show -p platepose -v intellectaco # one project only
|
|
242
|
+
\x05 localvault show -p platepose -v intellectaco --reveal
|
|
243
|
+
DESC
|
|
244
|
+
method_option :group, type: :boolean, default: false, desc: "Group flat keys by common prefix"
|
|
245
|
+
method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
|
|
246
|
+
method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
|
|
247
|
+
def show
|
|
248
|
+
vault = open_vault!
|
|
249
|
+
secrets = vault.all
|
|
250
|
+
|
|
251
|
+
if secrets.empty?
|
|
252
|
+
$stdout.puts "No secrets in vault '#{vault.name}'."
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if options[:project]
|
|
257
|
+
group = secrets[options[:project]]
|
|
258
|
+
unless group.is_a?(Hash)
|
|
259
|
+
abort_with "No project '#{options[:project]}' in vault '#{vault.name}'"
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
|
|
263
|
+
elsif options[:group] || secrets.values.any? { |v| v.is_a?(Hash) }
|
|
264
|
+
render_grouped_table(secrets, vault.name, reveal: options[:reveal])
|
|
265
|
+
else
|
|
266
|
+
render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
desc "rekey [NAME]", "Change the passphrase for a vault (secrets are preserved)"
|
|
271
|
+
def rekey(name = nil)
|
|
272
|
+
vault_name = name || resolve_vault_name
|
|
273
|
+
store = Store.new(vault_name)
|
|
274
|
+
|
|
275
|
+
unless store.exists?
|
|
276
|
+
abort_with "Vault '#{vault_name}' does not exist."
|
|
277
|
+
return
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
current = prompt_passphrase("Current passphrase: ")
|
|
281
|
+
vault = Vault.open(name: vault_name, passphrase: current)
|
|
282
|
+
vault.all # verify
|
|
283
|
+
|
|
284
|
+
new_pass = prompt_passphrase("New passphrase: ")
|
|
285
|
+
if new_pass.empty?
|
|
286
|
+
abort_with "Passphrase cannot be empty"
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
confirm = prompt_passphrase("Confirm new passphrase: ")
|
|
291
|
+
unless new_pass == confirm
|
|
292
|
+
abort_with "Passphrases do not match"
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
new_vault = vault.rekey(new_pass)
|
|
297
|
+
SessionCache.set(vault_name, new_vault.master_key)
|
|
298
|
+
$stdout.puts "Passphrase updated for vault '#{vault_name}'."
|
|
299
|
+
rescue Crypto::DecryptionError
|
|
300
|
+
abort_with "Wrong passphrase for vault '#{vault_name}'"
|
|
301
|
+
rescue RuntimeError => e
|
|
302
|
+
abort_with e.message
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
desc "reset [NAME]", "Destroy all secrets in a vault and reinitialize it"
|
|
306
|
+
def reset(name = nil)
|
|
307
|
+
vault_name = name || resolve_vault_name
|
|
308
|
+
store = Store.new(vault_name)
|
|
309
|
+
|
|
310
|
+
unless store.exists?
|
|
311
|
+
abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
$stderr.puts "WARNING: This will permanently delete all secrets in vault '#{vault_name}'."
|
|
316
|
+
$stderr.puts "This cannot be undone."
|
|
317
|
+
$stderr.print "Type '#{vault_name}' to confirm: "
|
|
318
|
+
|
|
319
|
+
confirmation = prompt_confirmation
|
|
320
|
+
unless confirmation == vault_name
|
|
321
|
+
abort_with "Cancelled."
|
|
322
|
+
return
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
store.destroy!
|
|
326
|
+
|
|
327
|
+
passphrase = prompt_passphrase("New passphrase: ")
|
|
328
|
+
if passphrase.empty?
|
|
329
|
+
abort_with "Passphrase cannot be empty"
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
confirm = prompt_passphrase("Confirm passphrase: ")
|
|
334
|
+
unless passphrase == confirm
|
|
335
|
+
abort_with "Passphrases do not match"
|
|
336
|
+
return
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
salt = Crypto.generate_salt
|
|
340
|
+
master_key = Crypto.derive_master_key(passphrase, salt)
|
|
341
|
+
Vault.create!(name: vault_name, master_key: master_key, salt: salt)
|
|
342
|
+
$stdout.puts "Vault '#{vault_name}' has been reset."
|
|
343
|
+
rescue RuntimeError => e
|
|
344
|
+
abort_with e.message
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
desc "lock [NAME]", "Clear cached passphrase for a vault (or all vaults)"
|
|
348
|
+
def lock(name = nil)
|
|
349
|
+
if name
|
|
350
|
+
SessionCache.clear(name)
|
|
351
|
+
$stdout.puts "Session cleared for vault '#{name}'."
|
|
352
|
+
else
|
|
353
|
+
SessionCache.clear_all
|
|
354
|
+
$stdout.puts "All vault sessions cleared."
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
desc "mcp", "Start MCP server (stdio)"
|
|
359
|
+
def mcp
|
|
360
|
+
require "localvault/mcp/server"
|
|
361
|
+
MCP::Server.new.start
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
desc "install-mcp [CLIENT]", "Configure localvault MCP server in your AI tool (default: claude-code)"
|
|
365
|
+
long_desc <<~DESC
|
|
366
|
+
Adds localvault as an MCP server so AI assistants can read and write your secrets.
|
|
367
|
+
|
|
368
|
+
Supported clients:
|
|
369
|
+
claude-code Adds to ~/.claude/settings.json (default)
|
|
370
|
+
cursor Adds to ~/.cursor/mcp.json
|
|
371
|
+
windsurf Adds to ~/.codeium/windsurf/mcp_config.json
|
|
372
|
+
|
|
373
|
+
The MCP server uses whichever vault is your current default (localvault switch).
|
|
374
|
+
Unlock the vault once with `localvault show`, then the AI tool picks it up via Keychain.
|
|
375
|
+
DESC
|
|
376
|
+
def install_mcp(client = "claude-code")
|
|
377
|
+
case client.downcase
|
|
378
|
+
when "claude-code" then install_for_claude_code
|
|
379
|
+
when "cursor" then install_mcp_via_json("Cursor", cursor_settings_path)
|
|
380
|
+
when "windsurf" then install_mcp_via_json("Windsurf", windsurf_settings_path)
|
|
381
|
+
else
|
|
382
|
+
abort_with "Unknown client '#{client}'. Supported: claude-code, cursor, windsurf"
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
desc "demo", "Create demo vaults with fake data for learning (passphrase: demo)"
|
|
387
|
+
def demo
|
|
388
|
+
names = Store.list_vaults
|
|
389
|
+
unless names.empty?
|
|
390
|
+
abort_with "Vaults already exist (#{names.join(", ")}). " \
|
|
391
|
+
"Run `localvault reset <name>` to clear one, or use a fresh LOCALVAULT_HOME."
|
|
392
|
+
return
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
$stderr.puts "This creates DEMO vaults with fake data for learning purposes."
|
|
396
|
+
$stderr.puts "These are NOT for real secrets. Passphrase for all vaults: \"demo\""
|
|
397
|
+
$stderr.print "Type 'demo' to continue: "
|
|
398
|
+
|
|
399
|
+
confirmation = prompt_confirmation
|
|
400
|
+
unless confirmation == "demo"
|
|
401
|
+
abort_with "Cancelled."
|
|
402
|
+
return
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
DEMO_DATA.each do |vault_name, secrets|
|
|
406
|
+
salt = Crypto.generate_salt
|
|
407
|
+
master_key = Crypto.derive_master_key("demo", salt)
|
|
408
|
+
vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
|
|
409
|
+
secrets.each { |k, v| vault.set(k, v) }
|
|
410
|
+
$stdout.puts " created vault '#{vault_name}' (#{secrets.size} secrets)"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
$stdout.puts
|
|
414
|
+
$stdout.puts "Done! All vaults use passphrase: demo"
|
|
415
|
+
$stdout.puts
|
|
416
|
+
$stdout.puts "Try:"
|
|
417
|
+
$stdout.puts " localvault vaults"
|
|
418
|
+
$stdout.puts " localvault show"
|
|
419
|
+
$stdout.puts " localvault show --vault x --group"
|
|
420
|
+
$stdout.puts " localvault show --vault production --reveal"
|
|
421
|
+
$stdout.puts " localvault exec -- env | grep -E 'DATABASE|REDIS'"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# ── Teams / sharing ──────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
require_relative "cli/keys"
|
|
427
|
+
require_relative "cli/team"
|
|
428
|
+
|
|
429
|
+
register(Keys, "keys", "keys SUBCOMMAND", "Manage your X25519 keypair for vault sharing")
|
|
430
|
+
register(Team, "team", "team SUBCOMMAND", "Manage vault team access")
|
|
431
|
+
|
|
432
|
+
desc "connect", "Connect to InventList for vault sharing"
|
|
433
|
+
method_option :token, required: true, type: :string, desc: "InventList API token"
|
|
434
|
+
method_option :handle, required: true, type: :string, desc: "Your InventList handle"
|
|
435
|
+
def connect
|
|
436
|
+
Config.token = options[:token]
|
|
437
|
+
Config.inventlist_handle = options[:handle]
|
|
438
|
+
$stdout.puts "Connected as @#{options[:handle]}"
|
|
439
|
+
$stdout.puts
|
|
440
|
+
$stdout.puts "Next steps:"
|
|
441
|
+
$stdout.puts " localvault keys generate # generate your X25519 keypair"
|
|
442
|
+
$stdout.puts " localvault keys publish # upload your public key to InventList"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
desc "share [VAULT]", "Share a vault with an InventList user, team, or crew"
|
|
446
|
+
method_option :with, required: true, type: :string,
|
|
447
|
+
desc: "Recipient: @handle, team:HANDLE, or crew:SLUG"
|
|
448
|
+
def share(vault_name = nil)
|
|
449
|
+
unless Config.token
|
|
450
|
+
abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
|
|
451
|
+
return
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
unless Identity.exists?
|
|
455
|
+
abort_with "No keypair found. Run: localvault keys generate && localvault keys publish"
|
|
456
|
+
return
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
vault_name ||= resolve_vault_name
|
|
460
|
+
vault = open_vault_by_name!(vault_name)
|
|
461
|
+
secrets = vault.all
|
|
462
|
+
|
|
463
|
+
if secrets.empty?
|
|
464
|
+
abort_with "Vault '#{vault_name}' has no secrets to share."
|
|
465
|
+
return
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
client = ApiClient.new(token: Config.token)
|
|
469
|
+
target = options[:with]
|
|
470
|
+
recipients = resolve_recipients(client, target)
|
|
471
|
+
|
|
472
|
+
if recipients.empty?
|
|
473
|
+
abort_with "No recipients with public keys found for '#{target}'"
|
|
474
|
+
return
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
recipients.each do |handle, pub_key|
|
|
478
|
+
encrypted = ShareCrypto.encrypt_for(secrets, pub_key)
|
|
479
|
+
client.create_share(
|
|
480
|
+
vault_name: vault_name,
|
|
481
|
+
recipient_handle: handle,
|
|
482
|
+
encrypted_payload: encrypted
|
|
483
|
+
)
|
|
484
|
+
$stdout.puts "Shared vault '#{vault_name}' with @#{handle}"
|
|
485
|
+
end
|
|
486
|
+
rescue ApiClient::ApiError => e
|
|
487
|
+
abort_with e.message
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
desc "receive", "Fetch and import vaults shared with you"
|
|
491
|
+
def receive
|
|
492
|
+
unless Config.token
|
|
493
|
+
abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
|
|
494
|
+
return
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
unless Identity.private_key_bytes
|
|
498
|
+
abort_with "No keypair found. Run: localvault keys generate"
|
|
499
|
+
return
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
client = ApiClient.new(token: Config.token)
|
|
503
|
+
result = client.pending_shares
|
|
504
|
+
shares = result["shares"] || []
|
|
505
|
+
|
|
506
|
+
if shares.empty?
|
|
507
|
+
$stdout.puts "No pending shares."
|
|
508
|
+
return
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
$stdout.puts "Found #{shares.size} pending share(s):"
|
|
512
|
+
$stdout.puts
|
|
513
|
+
|
|
514
|
+
imported = 0
|
|
515
|
+
shares.each do |share|
|
|
516
|
+
vault_name = "#{share["vault_name"]}-from-#{share["sender_handle"]}"
|
|
517
|
+
$stdout.puts " [#{share["id"]}] vault '#{share["vault_name"]}' from @#{share["sender_handle"]}"
|
|
518
|
+
|
|
519
|
+
begin
|
|
520
|
+
secrets = ShareCrypto.decrypt_from(share["encrypted_payload"], Identity.private_key_bytes)
|
|
521
|
+
rescue ShareCrypto::DecryptionError => e
|
|
522
|
+
$stderr.puts " Failed to decrypt: #{e.message}"
|
|
523
|
+
next
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
if Store.new(vault_name).exists?
|
|
527
|
+
$stdout.puts " Vault '#{vault_name}' already exists, skipping."
|
|
528
|
+
next
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
passphrase = prompt_passphrase(" Passphrase for new vault '#{vault_name}': ")
|
|
532
|
+
if passphrase.empty?
|
|
533
|
+
$stderr.puts " Skipped (empty passphrase)."
|
|
534
|
+
next
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
salt = Crypto.generate_salt
|
|
538
|
+
master_key = Crypto.derive_master_key(passphrase, salt)
|
|
539
|
+
vault = Vault.create!(name: vault_name, master_key: master_key, salt: salt)
|
|
540
|
+
secrets.each { |k, v| vault.set(k, v.to_s) }
|
|
541
|
+
|
|
542
|
+
$stdout.puts " Imported #{secrets.size} secret(s) → vault '#{vault_name}'"
|
|
543
|
+
client.accept_share(share["id"]) rescue nil
|
|
544
|
+
imported += 1
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
$stdout.puts
|
|
548
|
+
$stdout.puts "Done. #{imported} vault(s) imported."
|
|
549
|
+
rescue ApiClient::ApiError => e
|
|
550
|
+
abort_with e.message
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
desc "revoke SHARE_ID", "Revoke a vault share (stops future access)"
|
|
554
|
+
def revoke(share_id)
|
|
555
|
+
unless Config.token
|
|
556
|
+
abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
|
|
557
|
+
return
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
client = ApiClient.new(token: Config.token)
|
|
561
|
+
client.revoke_share(share_id)
|
|
562
|
+
$stdout.puts "Share #{share_id} revoked."
|
|
563
|
+
$stdout.puts "Note: @recipient retains any secrets already received."
|
|
564
|
+
rescue ApiClient::ApiError => e
|
|
565
|
+
abort_with e.message
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
desc "import FILE", "Bulk-import secrets from a .env, .json, or .yml file"
|
|
569
|
+
long_desc <<~DESC
|
|
570
|
+
Import all secrets from a file into a vault. Supports .env, .json, and .yml.
|
|
571
|
+
|
|
572
|
+
FLAT IMPORT (into default vault):
|
|
573
|
+
\x05 localvault import .env
|
|
574
|
+
\x05 localvault import secrets.json
|
|
575
|
+
|
|
576
|
+
SCOPED IMPORT (into a project group in a team vault):
|
|
577
|
+
\x05 localvault import .env -p platepose -v intellectaco
|
|
578
|
+
\x05 # → stores each key as platepose.KEY
|
|
579
|
+
|
|
580
|
+
NESTED JSON/YAML (auto-imported as project groups):
|
|
581
|
+
\x05 localvault import all-secrets.json -v intellectaco
|
|
582
|
+
\x05 # { "platepose": { "DB": "..." }, "inventlist": { "DB": "..." } }
|
|
583
|
+
\x05 # → platepose.DB, inventlist.DB
|
|
584
|
+
|
|
585
|
+
FILE FORMATS:
|
|
586
|
+
\x05 .env KEY=value lines, # comments ignored
|
|
587
|
+
\x05 .json flat {"KEY":"val"} or nested {"project":{"KEY":"val"}}
|
|
588
|
+
\x05 .yml flat KEY: value or nested project:\n KEY: value
|
|
589
|
+
DESC
|
|
590
|
+
method_option :project, aliases: "-p", type: :string, desc: "Namespace all imported keys under this project"
|
|
591
|
+
def import(file)
|
|
592
|
+
unless File.exist?(file)
|
|
593
|
+
abort_with "File not found: #{file}"
|
|
594
|
+
return
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
data = parse_import_file(file)
|
|
598
|
+
if data.nil? || data.empty?
|
|
599
|
+
abort_with "No secrets found in #{file}"
|
|
600
|
+
return
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
vault = open_vault!
|
|
604
|
+
project = options[:project]
|
|
605
|
+
count = 0
|
|
606
|
+
|
|
607
|
+
data.each do |key, value|
|
|
608
|
+
if value.is_a?(Hash)
|
|
609
|
+
value.each do |subkey, subval|
|
|
610
|
+
vault.set("#{key}.#{subkey}", subval.to_s)
|
|
611
|
+
count += 1
|
|
612
|
+
end
|
|
613
|
+
else
|
|
614
|
+
dest_key = project ? "#{project}.#{key}" : key
|
|
615
|
+
vault.set(dest_key, value.to_s)
|
|
616
|
+
count += 1
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
$stdout.puts "Imported #{count} secret(s) into vault '#{vault.name}'" \
|
|
621
|
+
"#{project ? " / #{project}" : ""}."
|
|
622
|
+
rescue RuntimeError => e
|
|
623
|
+
abort_with e.message
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
desc "rename OLD NEW", "Rename a secret key (supports dot-notation)"
|
|
627
|
+
long_desc <<~DESC
|
|
628
|
+
Rename a key in-place. The value is preserved; only the key name changes.
|
|
629
|
+
|
|
630
|
+
FLAT KEYS:
|
|
631
|
+
\x05 localvault rename OLD_NAME NEW_NAME
|
|
632
|
+
|
|
633
|
+
NESTED KEYS:
|
|
634
|
+
\x05 localvault rename platepose.DB_URL platepose.DATABASE_URL -v intellectaco
|
|
635
|
+
|
|
636
|
+
MOVE ACROSS PROJECTS:
|
|
637
|
+
\x05 localvault rename staging.SECRET_KEY_BASE production.SECRET_KEY_BASE -v intellectaco
|
|
638
|
+
DESC
|
|
639
|
+
def rename(old_key, new_key)
|
|
640
|
+
vault = open_vault!
|
|
641
|
+
value = vault.get(old_key)
|
|
642
|
+
if value.nil?
|
|
643
|
+
abort_with "Key '#{old_key}' not found in vault '#{vault.name}'"
|
|
644
|
+
return
|
|
645
|
+
end
|
|
646
|
+
vault.set(new_key, value)
|
|
647
|
+
vault.delete(old_key)
|
|
648
|
+
$stdout.puts "Renamed '#{old_key}' → '#{new_key}' in vault '#{vault.name}'"
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
desc "copy KEY --to VAULT", "Copy a secret to another vault"
|
|
652
|
+
long_desc <<~DESC
|
|
653
|
+
Copy a secret from the current vault to a different vault.
|
|
654
|
+
Great for promoting secrets from staging → production.
|
|
655
|
+
|
|
656
|
+
COPY A FLAT KEY:
|
|
657
|
+
\x05 localvault copy STRIPE_KEY --to production
|
|
658
|
+
|
|
659
|
+
COPY A NESTED KEY (key name preserved in destination):
|
|
660
|
+
\x05 localvault copy platepose.DATABASE_URL --to production -v intellectaco
|
|
661
|
+
|
|
662
|
+
Use `localvault rename` afterwards if you need a different key name.
|
|
663
|
+
DESC
|
|
664
|
+
method_option :to, required: true, type: :string, desc: "Destination vault name"
|
|
665
|
+
def copy(key)
|
|
666
|
+
src_vault = open_vault!
|
|
667
|
+
value = src_vault.get(key)
|
|
668
|
+
if value.nil?
|
|
669
|
+
abort_with "Key '#{key}' not found in vault '#{src_vault.name}'"
|
|
670
|
+
return
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
dst_vault = open_vault_by_name!(options[:to])
|
|
674
|
+
dst_vault.set(key, value)
|
|
675
|
+
$stdout.puts "Copied '#{key}' from '#{src_vault.name}' to '#{dst_vault.name}'"
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
desc "switch [VAULT]", "Switch the default vault (or show current)"
|
|
679
|
+
def switch(vault_name = nil)
|
|
680
|
+
if vault_name.nil?
|
|
681
|
+
current = Config.default_vault
|
|
682
|
+
$stdout.puts "Current vault: #{current}"
|
|
683
|
+
$stdout.puts
|
|
684
|
+
$stdout.puts "Available vaults:"
|
|
685
|
+
Store.list_vaults.each do |name|
|
|
686
|
+
marker = name == current ? " ← current" : ""
|
|
687
|
+
$stdout.puts " #{name}#{marker}"
|
|
688
|
+
end
|
|
689
|
+
return
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
unless Store.new(vault_name).exists?
|
|
693
|
+
abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
694
|
+
return
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
Config.default_vault = vault_name
|
|
698
|
+
$stdout.puts "Switched to vault '#{vault_name}'"
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
desc "version", "Print version"
|
|
702
|
+
def version
|
|
703
|
+
$stdout.puts "localvault #{VERSION}"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def self.exit_on_failure?
|
|
707
|
+
true
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
no_commands do
|
|
711
|
+
def prompt_confirmation(msg = "")
|
|
712
|
+
$stdin.gets&.chomp || ""
|
|
713
|
+
rescue Interrupt
|
|
714
|
+
$stderr.puts
|
|
715
|
+
exit 130
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def prompt_passphrase(msg = "Passphrase: ")
|
|
719
|
+
unless $stdin.respond_to?(:getpass) || ($stdin.respond_to?(:tty?) && $stdin.tty?)
|
|
720
|
+
abort_with "Use LOCALVAULT_SESSION or run in a terminal"
|
|
721
|
+
return ""
|
|
722
|
+
end
|
|
723
|
+
IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
|
|
724
|
+
rescue Interrupt
|
|
725
|
+
$stderr.puts
|
|
726
|
+
exit 130
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
private
|
|
731
|
+
|
|
732
|
+
# ── Demo data ──────────────────────────────────────────────────
|
|
733
|
+
DEMO_DATA = {
|
|
734
|
+
"default" => {
|
|
735
|
+
"OPENAI_API_KEY" => "sk-demo-openai-abc123",
|
|
736
|
+
"ANTHROPIC_API_KEY" => "sk-ant-demo-xyz789",
|
|
737
|
+
"STRIPE_SECRET_KEY" => "sk_test_demo_stripe",
|
|
738
|
+
"STRIPE_WEBHOOK_SECRET" => "whsec_demo_webhook",
|
|
739
|
+
"RESEND_API_KEY" => "re_demo_resend_key",
|
|
740
|
+
"GITHUB_TOKEN" => "ghp_demo_github_token",
|
|
741
|
+
"SENTRY_DSN" => "https://demo@sentry.io/12345",
|
|
742
|
+
"DATABASE_URL" => "postgres://localhost/myapp_dev"
|
|
743
|
+
},
|
|
744
|
+
"x" => {
|
|
745
|
+
"NAUMANTHANVI_API_KEY" => "demo-api-key-personal",
|
|
746
|
+
"NAUMANTHANVI_API_SECRET" => "demo-api-secret-personal",
|
|
747
|
+
"NAUMANTHANVI_ACCESS_TOKEN" => "demo-access-token-personal",
|
|
748
|
+
"NAUMANTHANVI_ACCESS_SECRET" => "demo-access-secret-personal",
|
|
749
|
+
"NAUMANTHANVI_BEARER_TOKEN" => "demo-bearer-personal",
|
|
750
|
+
"INVENT_LIST_API_KEY" => "demo-api-key-brand",
|
|
751
|
+
"INVENT_LIST_API_SECRET" => "demo-api-secret-brand",
|
|
752
|
+
"INVENT_LIST_ACCESS_TOKEN" => "demo-access-token-brand",
|
|
753
|
+
"INVENT_LIST_ACCESS_SECRET" => "demo-access-secret-brand",
|
|
754
|
+
"INVENT_LIST_BEARER_TOKEN" => "demo-bearer-brand"
|
|
755
|
+
},
|
|
756
|
+
"production" => {
|
|
757
|
+
"DATABASE_URL" => "postgres://prod-db.example.com/myapp",
|
|
758
|
+
"REDIS_URL" => "redis://prod-redis.example.com:6379",
|
|
759
|
+
"SECRET_KEY_BASE" => "demo-secret-key-base-very-long-string",
|
|
760
|
+
"RAILS_MASTER_KEY" => "demo-master-key-32chars-exactly!",
|
|
761
|
+
"AWS_ACCESS_KEY_ID" => "AKIADEMO0000000000",
|
|
762
|
+
"AWS_SECRET_ACCESS_KEY" => "demo-aws-secret-access-key",
|
|
763
|
+
"S3_BUCKET" => "myapp-production",
|
|
764
|
+
"CLOUDFLARE_API_TOKEN" => "demo-cloudflare-token",
|
|
765
|
+
"KAMAL_REGISTRY_PASSWORD" => "demo-registry-password"
|
|
766
|
+
},
|
|
767
|
+
"staging" => {
|
|
768
|
+
"DATABASE_URL" => "postgres://staging-db.example.com/myapp",
|
|
769
|
+
"REDIS_URL" => "redis://staging-redis.example.com:6379",
|
|
770
|
+
"SECRET_KEY_BASE" => "demo-staging-secret-key-base",
|
|
771
|
+
"RAILS_MASTER_KEY" => "demo-staging-master-key-32ch!",
|
|
772
|
+
"STRIPE_SECRET_KEY" => "sk_test_demo_staging_stripe",
|
|
773
|
+
"STRIPE_WEBHOOK_SECRET" => "whsec_demo_staging_webhook",
|
|
774
|
+
"S3_BUCKET" => "myapp-staging"
|
|
775
|
+
}
|
|
776
|
+
}.freeze
|
|
777
|
+
|
|
778
|
+
# ── Lipgloss styles ────────────────────────────────────────────
|
|
779
|
+
HEADER_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF").background("#5C4AE4").padding(0, 1)
|
|
780
|
+
ODD_STYLE = Lipgloss::Style.new.foreground("#E2E2E2").padding(0, 1)
|
|
781
|
+
EVEN_STYLE = Lipgloss::Style.new.foreground("#A0A0A0").padding(0, 1)
|
|
782
|
+
MASKED_STYLE = Lipgloss::Style.new.foreground("#6B7280").padding(0, 1)
|
|
783
|
+
GROUP_STYLE = Lipgloss::Style.new.bold(true).foreground("#A78BFA")
|
|
784
|
+
VAULT_STYLE = Lipgloss::Style.new.bold(true).foreground("#FFFFFF")
|
|
785
|
+
COUNT_STYLE = Lipgloss::Style.new.foreground("#6B7280")
|
|
786
|
+
|
|
787
|
+
def mask_value(value, reveal:)
|
|
788
|
+
return value if reveal
|
|
789
|
+
return "(empty)" if value.to_s.empty?
|
|
790
|
+
suffix = value.to_s.length > 4 ? value.to_s[-4..] : value.to_s
|
|
791
|
+
"#{"•" * 6} #{suffix}"
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def lipgloss_table(secrets, reveal:)
|
|
795
|
+
require "lipgloss"
|
|
796
|
+
rows = secrets.sort.map { |k, v| [k, mask_value(v, reveal: reveal)] }
|
|
797
|
+
Lipgloss::Table.new
|
|
798
|
+
.headers(["Key", "Value"])
|
|
799
|
+
.rows(rows)
|
|
800
|
+
.border(:rounded)
|
|
801
|
+
.style_func(rows: rows.size, columns: 2) do |row, _col|
|
|
802
|
+
if row == Lipgloss::Table::HEADER_ROW
|
|
803
|
+
HEADER_STYLE
|
|
804
|
+
elsif reveal
|
|
805
|
+
row.odd? ? ODD_STYLE : EVEN_STYLE
|
|
806
|
+
else
|
|
807
|
+
row.odd? ? MASKED_STYLE : EVEN_STYLE
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
.render
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def render_table(secrets, vault_name, reveal:, header: nil)
|
|
814
|
+
unless header == false
|
|
815
|
+
total = secrets.size
|
|
816
|
+
label = "#{VAULT_STYLE.render("Vault: #{vault_name}")} #{COUNT_STYLE.render("(#{total} secret#{total == 1 ? "" : "s"})")}"
|
|
817
|
+
$stdout.puts label
|
|
818
|
+
end
|
|
819
|
+
$stdout.puts lipgloss_table(secrets, reveal: reveal)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def render_grouped_table(secrets, vault_name, reveal:)
|
|
823
|
+
# Separate true nested groups (Hash values) from flat keys
|
|
824
|
+
nested = secrets.select { |_, v| v.is_a?(Hash) }
|
|
825
|
+
flat = secrets.reject { |_, v| v.is_a?(Hash) }
|
|
826
|
+
|
|
827
|
+
# For flat keys, group by underscore prefix (legacy --group behaviour)
|
|
828
|
+
prefix_groups = flat.group_by { |k, _| k.include?("_") ? k.split("_").first : nil }
|
|
829
|
+
ungrouped = prefix_groups.delete(nil) || []
|
|
830
|
+
|
|
831
|
+
total = secrets.sum { |_, v| v.is_a?(Hash) ? v.size : 1 }
|
|
832
|
+
$stdout.puts "#{VAULT_STYLE.render("Vault: #{vault_name}")} #{COUNT_STYLE.render("(#{total} secret#{total == 1 ? "" : "s"})")}"
|
|
833
|
+
$stdout.puts
|
|
834
|
+
|
|
835
|
+
# Render nested project groups first
|
|
836
|
+
nested.sort.each do |project, pairs|
|
|
837
|
+
$stdout.puts " #{GROUP_STYLE.render(project)} #{COUNT_STYLE.render("(#{pairs.size})")}"
|
|
838
|
+
$stdout.puts lipgloss_table(pairs.sort.to_h, reveal: reveal)
|
|
839
|
+
$stdout.puts
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Render flat prefix groups
|
|
843
|
+
prefix_groups.sort.each do |prefix, pairs|
|
|
844
|
+
$stdout.puts " #{GROUP_STYLE.render(prefix)} #{COUNT_STYLE.render("(#{pairs.size})")}"
|
|
845
|
+
$stdout.puts lipgloss_table(pairs.sort.to_h, reveal: reveal)
|
|
846
|
+
$stdout.puts
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
unless ungrouped.empty?
|
|
850
|
+
$stdout.puts " #{GROUP_STYLE.render("ungrouped")}"
|
|
851
|
+
$stdout.puts lipgloss_table(ungrouped.sort.to_h, reveal: reveal)
|
|
852
|
+
$stdout.puts
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def parse_import_file(file)
|
|
857
|
+
ext = File.extname(file).downcase
|
|
858
|
+
case ext
|
|
859
|
+
when ".json"
|
|
860
|
+
require "json"
|
|
861
|
+
JSON.parse(File.read(file))
|
|
862
|
+
when ".yml", ".yaml"
|
|
863
|
+
require "yaml"
|
|
864
|
+
YAML.safe_load(File.read(file)) || {}
|
|
865
|
+
else
|
|
866
|
+
# Treat as .env regardless of extension
|
|
867
|
+
File.readlines(file, chomp: true).each_with_object({}) do |line, h|
|
|
868
|
+
next if line.strip.empty? || line.strip.start_with?("#")
|
|
869
|
+
key, val = line.split("=", 2)
|
|
870
|
+
h[key.strip] = val.to_s.strip if key
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def resolve_vault_name
|
|
876
|
+
options[:vault] || Config.default_vault
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
def open_vault_by_name!(vault_name)
|
|
880
|
+
if (vault = vault_from_session(vault_name))
|
|
881
|
+
return vault
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
store = Store.new(vault_name)
|
|
885
|
+
unless store.exists?
|
|
886
|
+
abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
887
|
+
raise SystemExit.new(1)
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
if (master_key = SessionCache.get(vault_name))
|
|
891
|
+
begin
|
|
892
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
893
|
+
vault.all
|
|
894
|
+
return vault
|
|
895
|
+
rescue Crypto::DecryptionError
|
|
896
|
+
SessionCache.clear(vault_name)
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
passphrase = prompt_passphrase("Passphrase for '#{vault_name}': ")
|
|
901
|
+
vault = Vault.open(name: vault_name, passphrase: passphrase)
|
|
902
|
+
vault.all
|
|
903
|
+
SessionCache.set(vault_name, vault.master_key)
|
|
904
|
+
vault
|
|
905
|
+
rescue Crypto::DecryptionError
|
|
906
|
+
abort_with "Wrong passphrase for vault '#{vault_name}'"
|
|
907
|
+
raise SystemExit.new(1)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def resolve_recipients(client, target)
|
|
911
|
+
if target.start_with?("team:")
|
|
912
|
+
handle = target.delete_prefix("team:")
|
|
913
|
+
result = client.team_public_keys(handle)
|
|
914
|
+
(result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
|
|
915
|
+
elsif target.start_with?("crew:")
|
|
916
|
+
slug = target.delete_prefix("crew:")
|
|
917
|
+
result = client.crew_public_keys(slug)
|
|
918
|
+
(result["members"] || []).map { |m| [m["handle"], m["public_key"]] }
|
|
919
|
+
else
|
|
920
|
+
handle = target.delete_prefix("@")
|
|
921
|
+
result = client.get_public_key(handle)
|
|
922
|
+
[[result["handle"], result["public_key"]]]
|
|
923
|
+
end
|
|
924
|
+
rescue ApiClient::ApiError => e
|
|
925
|
+
$stderr.puts "Warning: #{e.message}"
|
|
926
|
+
[]
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def open_vault!
|
|
930
|
+
vault_name = resolve_vault_name
|
|
931
|
+
|
|
932
|
+
# 1. Try LOCALVAULT_SESSION env var
|
|
933
|
+
if (vault = vault_from_session(vault_name))
|
|
934
|
+
return vault
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
store = Store.new(vault_name)
|
|
938
|
+
unless store.exists?
|
|
939
|
+
abort_with "Vault '#{vault_name}' does not exist. Run: localvault init #{vault_name}"
|
|
940
|
+
raise SystemExit.new(1)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# 2. Try Keychain session cache
|
|
944
|
+
if (master_key = SessionCache.get(vault_name))
|
|
945
|
+
begin
|
|
946
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
947
|
+
vault.all # verify key still valid
|
|
948
|
+
return vault
|
|
949
|
+
rescue Crypto::DecryptionError
|
|
950
|
+
SessionCache.clear(vault_name) # stale cache — clear and fall through
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# 3. Prompt passphrase and cache the result
|
|
955
|
+
passphrase = prompt_passphrase("Passphrase: ")
|
|
956
|
+
vault = Vault.open(name: vault_name, passphrase: passphrase)
|
|
957
|
+
vault.all # eager verification
|
|
958
|
+
SessionCache.set(vault_name, vault.master_key)
|
|
959
|
+
vault
|
|
960
|
+
rescue Crypto::DecryptionError
|
|
961
|
+
abort_with "Wrong passphrase for vault '#{vault_name}'"
|
|
962
|
+
raise SystemExit.new(1)
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def vault_from_session(vault_name)
|
|
966
|
+
token = ENV["LOCALVAULT_SESSION"]
|
|
967
|
+
return nil unless token
|
|
968
|
+
|
|
969
|
+
decoded = Base64.strict_decode64(token)
|
|
970
|
+
session_vault, key_b64 = decoded.split(":", 2)
|
|
971
|
+
return nil unless session_vault == vault_name && key_b64
|
|
972
|
+
|
|
973
|
+
master_key = Base64.strict_decode64(key_b64)
|
|
974
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
975
|
+
vault.all # Verify the key works
|
|
976
|
+
vault
|
|
977
|
+
rescue ArgumentError, Crypto::DecryptionError
|
|
978
|
+
nil
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def abort_with(message)
|
|
982
|
+
$stderr.puts "Error: #{message}"
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# --- install-mcp helpers ---
|
|
986
|
+
|
|
987
|
+
# Claude Code: use `claude mcp add --scope user` so the server is
|
|
988
|
+
# registered globally (user scope) — not tied to a single project.
|
|
989
|
+
def install_for_claude_code
|
|
990
|
+
unless system_command_exists?("claude")
|
|
991
|
+
abort_with "Claude Code CLI not found. Install it from https://claude.ai/code"
|
|
992
|
+
return
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
localvault_bin = find_binary("localvault")
|
|
996
|
+
if localvault_bin.nil?
|
|
997
|
+
abort_with "localvault not found in PATH"
|
|
998
|
+
return
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# Remove existing entry first (idempotent)
|
|
1002
|
+
system("claude", "mcp", "remove", "localvault", "--scope", "user",
|
|
1003
|
+
out: File::NULL, err: File::NULL)
|
|
1004
|
+
|
|
1005
|
+
success = system("claude", "mcp", "add", "--scope", "user",
|
|
1006
|
+
"localvault", localvault_bin, "mcp")
|
|
1007
|
+
|
|
1008
|
+
if success
|
|
1009
|
+
$stdout.puts "Added localvault MCP server to Claude Code (user scope — global)"
|
|
1010
|
+
print_next_steps("Claude Code")
|
|
1011
|
+
else
|
|
1012
|
+
abort_with "Failed to add MCP server. Try: claude mcp add --scope user localvault #{localvault_bin} mcp"
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Cursor / Windsurf / others: write to their JSON config file directly.
|
|
1017
|
+
def install_mcp_via_json(client_name, config_path)
|
|
1018
|
+
require "json"
|
|
1019
|
+
require "fileutils"
|
|
1020
|
+
|
|
1021
|
+
localvault_bin = find_binary("localvault")
|
|
1022
|
+
if localvault_bin.nil?
|
|
1023
|
+
abort_with "localvault not found in PATH"
|
|
1024
|
+
return
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
FileUtils.mkdir_p(File.dirname(config_path))
|
|
1028
|
+
|
|
1029
|
+
settings = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
|
|
1030
|
+
existing = settings.dig("mcpServers", "localvault")
|
|
1031
|
+
|
|
1032
|
+
settings["mcpServers"] ||= {}
|
|
1033
|
+
settings["mcpServers"]["localvault"] = {
|
|
1034
|
+
"command" => localvault_bin,
|
|
1035
|
+
"args" => ["mcp"]
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
File.write(config_path, JSON.pretty_generate(settings) + "\n")
|
|
1039
|
+
|
|
1040
|
+
verb = existing ? "Updated" : "Added"
|
|
1041
|
+
$stdout.puts "#{verb} localvault MCP server in #{client_name} (#{config_path})"
|
|
1042
|
+
print_next_steps(client_name)
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
def print_next_steps(client_name)
|
|
1046
|
+
$stdout.puts ""
|
|
1047
|
+
$stdout.puts "Next steps:"
|
|
1048
|
+
$stdout.puts " 1. Restart #{client_name}"
|
|
1049
|
+
$stdout.puts " 2. Unlock your vault once: localvault show"
|
|
1050
|
+
$stdout.puts " 3. The AI can now access secrets from your default vault"
|
|
1051
|
+
$stdout.puts " Switch vaults: localvault switch <vault>"
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
no_commands do
|
|
1055
|
+
def find_binary(name)
|
|
1056
|
+
path = `which #{name} 2>/dev/null`.strip
|
|
1057
|
+
path.empty? ? nil : path
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def system_command_exists?(cmd)
|
|
1061
|
+
!find_binary(cmd).nil?
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def cursor_settings_path
|
|
1065
|
+
File.expand_path("~/.cursor/mcp.json")
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
def windsurf_settings_path
|
|
1069
|
+
File.expand_path("~/.codeium/windsurf/mcp_config.json")
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
end
|