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.
@@ -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