localvault 1.0.5 → 1.2.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.
@@ -1,18 +1,113 @@
1
1
  require "thor"
2
+ require "securerandom"
2
3
 
3
4
  module LocalVault
4
5
  class CLI
5
6
  class Team < Thor
7
+ desc "init", "Initialize a vault as a team vault (sets you as owner)"
8
+ method_option :vault, type: :string, aliases: "-v"
9
+ # Initialize a vault as a team vault with you as the owner.
10
+ #
11
+ # This is the explicit transition from personal sync to team-shared sync.
12
+ # Creates the owner's key slot and bumps the bundle to v3.
13
+ def init
14
+ unless Config.token
15
+ $stderr.puts "Error: Not logged in."
16
+ $stderr.puts "\n localvault login YOUR_TOKEN\n"
17
+ $stderr.puts "Get your token at: https://inventlist.com/settings"
18
+ return
19
+ end
20
+
21
+ unless Identity.exists?
22
+ $stderr.puts "Error: No keypair found. Run: localvault keygen"
23
+ return
24
+ end
25
+
26
+ vault_name = options[:vault] || Config.default_vault
27
+ handle = Config.inventlist_handle
28
+
29
+ master_key = SessionCache.get(vault_name)
30
+ unless master_key
31
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
32
+ return
33
+ end
34
+
35
+ client = ApiClient.new(token: Config.token)
36
+ begin
37
+ blob = client.pull_vault(vault_name)
38
+ unless blob.is_a?(String) && !blob.empty?
39
+ $stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
40
+ return
41
+ end
42
+ data = SyncBundle.unpack(blob)
43
+ if data[:owner]
44
+ $stderr.puts "Error: Vault '#{vault_name}' is already a team vault. Owner: @#{data[:owner]}"
45
+ return
46
+ end
47
+ rescue ApiClient::ApiError => e
48
+ if e.status == 404
49
+ $stderr.puts "Error: Vault '#{vault_name}' has not been synced. Run: localvault sync push -v #{vault_name}"
50
+ else
51
+ $stderr.puts "Error: #{e.message}"
52
+ end
53
+ return
54
+ end
55
+
56
+ # Create owner key slot
57
+ pub_b64 = Identity.public_key
58
+ enc_key = KeySlot.create(master_key, pub_b64)
59
+ key_slots = {
60
+ handle => { "pub" => pub_b64, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
61
+ }
62
+
63
+ # Preserve existing key slots from v2 (upgrade path)
64
+ data[:key_slots].each do |h, slot|
65
+ next if h == handle
66
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
67
+ key_slots[h] = slot.merge("scopes" => nil, "blob" => nil)
68
+ end
69
+
70
+ store = Store.new(vault_name)
71
+ new_blob = SyncBundle.pack_v3(store, owner: handle, key_slots: key_slots)
72
+ client.push_vault(vault_name, new_blob)
73
+
74
+ $stdout.puts "Vault '#{vault_name}' is now a team vault."
75
+ $stdout.puts "Owner: @#{handle}"
76
+ $stdout.puts "\nNext: localvault team add @handle -v #{vault_name}"
77
+ rescue SyncBundle::UnpackError => e
78
+ $stderr.puts "Error: #{e.message}"
79
+ end
80
+
6
81
  desc "list [VAULT]", "Show who has access to a vault"
7
82
  method_option :vault, type: :string, aliases: "-v"
83
+ # List all users who have access to a vault.
84
+ #
85
+ # Checks sync-based key slots first; falls back to direct shares if no
86
+ # key slots exist. Displays member handles (key slots) or a share table
87
+ # with ID, recipient, status, and date.
8
88
  def list(vault_name = nil)
9
89
  unless Config.token
10
- $stderr.puts "Error: Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
90
+ $stderr.puts "Error: Not logged in."
91
+ $stderr.puts
92
+ $stderr.puts " localvault login YOUR_TOKEN"
93
+ $stderr.puts
94
+ $stderr.puts "Get your token at: https://inventlist.com/settings"
95
+ $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
96
+ $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
11
97
  return
12
98
  end
13
99
 
14
100
  vault_name ||= options[:vault] || Config.default_vault
15
101
  client = ApiClient.new(token: Config.token)
102
+
103
+ # Try sync-based key slots first
104
+ key_slots = load_key_slots(client, vault_name)
105
+ if key_slots && !key_slots.empty?
106
+ list_key_slots(vault_name, key_slots)
107
+ return
108
+ end
109
+
110
+ # Fall back to direct shares
16
111
  result = client.sent_shares(vault_name: vault_name)
17
112
  shares = (result["shares"] || []).reject { |s| s["status"] == "revoked" }
18
113
 
@@ -34,11 +129,151 @@ module LocalVault
34
129
  $stderr.puts "Error: #{e.message}"
35
130
  end
36
131
 
132
+ desc "add HANDLE", "Add a teammate to a synced vault via key slot"
133
+ method_option :vault, type: :string, aliases: "-v"
134
+ method_option :scope, type: :array, desc: "Groups or keys to share (omit for full access)"
135
+ # Grant a user access to a synced vault by creating a key slot.
136
+ #
137
+ # With --scope, creates a per-member encrypted blob containing only the
138
+ # specified keys. Without --scope, grants full vault access.
139
+ # Requires the vault to be a team vault (run team init first).
140
+ def add(handle)
141
+ unless Config.token
142
+ $stderr.puts "Error: Not logged in."
143
+ $stderr.puts "\n localvault login YOUR_TOKEN\n"
144
+ $stderr.puts "Get your token at: https://inventlist.com/settings"
145
+ return
146
+ end
147
+
148
+ unless Identity.exists?
149
+ $stderr.puts "Error: No keypair found. Run: localvault keygen"
150
+ return
151
+ end
152
+
153
+ handle = handle.delete_prefix("@")
154
+ vault_name = options[:vault] || Config.default_vault
155
+ scope_list = options[:scope]
156
+
157
+ master_key = SessionCache.get(vault_name)
158
+ unless master_key
159
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
160
+ return
161
+ end
162
+
163
+ client = ApiClient.new(token: Config.token)
164
+
165
+ # Load existing bundle — must be a team vault (v3)
166
+ existing_blob = client.pull_vault(vault_name) rescue nil
167
+ unless existing_blob.is_a?(String) && !existing_blob.empty?
168
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
169
+ return
170
+ end
171
+
172
+ data = SyncBundle.unpack(existing_blob)
173
+ unless data[:owner]
174
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
175
+ return
176
+ end
177
+
178
+ # Only owner can add members
179
+ unless data[:owner] == Config.inventlist_handle
180
+ $stderr.puts "Error: Only the vault owner (@#{data[:owner]}) can manage team access."
181
+ return
182
+ end
183
+
184
+ key_slots = data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
185
+
186
+ # Check if member already has full access
187
+ if key_slots.key?(handle) && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].nil?
188
+ if scope_list
189
+ $stdout.puts "@#{handle} already has full vault access."
190
+ return
191
+ end
192
+ end
193
+
194
+ # Fetch recipient's public key
195
+ result = client.get_public_key(handle)
196
+ pub_key = result["public_key"]
197
+ unless pub_key && !pub_key.empty?
198
+ $stderr.puts "Error: @#{handle} has no public key published."
199
+ return
200
+ end
201
+
202
+ if scope_list
203
+ # Accumulate scopes if member already has some
204
+ existing_scopes = key_slots.dig(handle, "scopes") || []
205
+ merged_scopes = (existing_scopes + scope_list).uniq
206
+
207
+ # Create per-member blob with filtered secrets
208
+ vault = Vault.new(name: vault_name, master_key: master_key)
209
+ filtered = vault.filter(merged_scopes)
210
+
211
+ member_key = RbNaCl::Random.random_bytes(32)
212
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
213
+
214
+ begin
215
+ enc_key = KeySlot.create(member_key, pub_key)
216
+ rescue ArgumentError, KeySlot::DecryptionError => e
217
+ $stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
218
+ return
219
+ end
220
+
221
+ key_slots[handle] = {
222
+ "pub" => pub_key,
223
+ "enc_key" => enc_key,
224
+ "scopes" => merged_scopes,
225
+ "blob" => Base64.strict_encode64(encrypted_blob)
226
+ }
227
+ else
228
+ # Full vault access — encrypt master key directly
229
+ begin
230
+ enc_key = KeySlot.create(master_key, pub_key)
231
+ rescue ArgumentError, KeySlot::DecryptionError => e
232
+ $stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
233
+ return
234
+ end
235
+
236
+ key_slots[handle] = { "pub" => pub_key, "enc_key" => enc_key, "scopes" => nil, "blob" => nil }
237
+ end
238
+
239
+ store = Store.new(vault_name)
240
+ blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
241
+ client.push_vault(vault_name, blob)
242
+
243
+ if scope_list
244
+ $stdout.puts "Added @#{handle} to vault '#{vault_name}' (scopes: #{key_slots[handle]["scopes"].join(", ")})."
245
+ else
246
+ $stdout.puts "Added @#{handle} to vault '#{vault_name}'."
247
+ end
248
+ rescue ApiClient::ApiError => e
249
+ if e.status == 404
250
+ $stderr.puts "Error: @#{handle} not found or has no public key."
251
+ else
252
+ $stderr.puts "Error: #{e.message}"
253
+ end
254
+ rescue SyncBundle::UnpackError => e
255
+ $stderr.puts "Error: #{e.message}"
256
+ end
257
+
37
258
  desc "remove HANDLE", "Remove a person's access to a vault"
38
259
  method_option :vault, type: :string, aliases: "-v"
260
+ method_option :rotate, type: :boolean, default: false, desc: "Re-encrypt vault with new master key (full revocation)"
261
+ method_option :scope, type: :array, desc: "Remove specific scopes only (keeps other scopes)"
262
+ # Remove a user's access to a vault.
263
+ #
264
+ # Removes the user's key slot and pushes the updated bundle. With +--rotate+,
265
+ # re-encrypts the vault with a new master key and recreates all remaining
266
+ # key slots for full cryptographic revocation. Falls back to revoking a
267
+ # direct share if no key slots exist.
39
268
  def remove(handle)
40
269
  unless Config.token
41
- $stderr.puts "Error: Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
270
+ $stderr.puts "Error: Not logged in."
271
+ $stderr.puts
272
+ $stderr.puts " localvault login YOUR_TOKEN"
273
+ $stderr.puts
274
+ $stderr.puts "Get your token at: https://inventlist.com/settings"
275
+ $stderr.puts "New to InventList? Sign up free at https://inventlist.com"
276
+ $stderr.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
42
277
  return
43
278
  end
44
279
 
@@ -46,6 +281,25 @@ module LocalVault
46
281
  vault_name = options[:vault] || Config.default_vault
47
282
  client = ApiClient.new(token: Config.token)
48
283
 
284
+ # Try sync-based key slot removal first
285
+ team_data = load_team_data(client, vault_name)
286
+ if team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
287
+ # Must be a v3 team vault with owner
288
+ unless team_data[:owner]
289
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
290
+ return
291
+ end
292
+ unless team_data[:owner] == Config.inventlist_handle
293
+ $stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can manage team access."
294
+ return
295
+ end
296
+ remove_key_slot(handle, vault_name, team_data[:key_slots], client,
297
+ rotate: options[:rotate], remove_scopes: options[:scope],
298
+ owner: team_data[:owner])
299
+ return
300
+ end
301
+
302
+ # Fall back to direct share revocation
49
303
  result = client.sent_shares(vault_name: vault_name)
50
304
  share = (result["shares"] || []).find do |s|
51
305
  s["recipient_handle"] == handle && s["status"] != "revoked"
@@ -61,6 +315,242 @@ module LocalVault
61
315
  rescue ApiClient::ApiError => e
62
316
  $stderr.puts "Error: #{e.message}"
63
317
  end
318
+
319
+ desc "rotate", "Re-encrypt a team vault with a new master key (no member changes)"
320
+ method_option :vault, type: :string, aliases: "-v"
321
+ # Re-key a team vault without adding or removing members.
322
+ #
323
+ # Prompts for a new passphrase, re-encrypts all secrets, and rebuilds
324
+ # all key slots. Useful for periodic key rotation.
325
+ def rotate
326
+ unless Config.token
327
+ $stderr.puts "Error: Not logged in."
328
+ return
329
+ end
330
+
331
+ vault_name = options[:vault] || Config.default_vault
332
+ client = ApiClient.new(token: Config.token)
333
+
334
+ team_data = load_team_data(client, vault_name)
335
+ unless team_data && team_data[:key_slots] && !team_data[:key_slots].empty?
336
+ $stderr.puts "Error: Vault '#{vault_name}' has no team access. Nothing to rotate."
337
+ return
338
+ end
339
+
340
+ unless team_data[:owner]
341
+ $stderr.puts "Error: Vault '#{vault_name}' is not a team vault. Run: localvault team init -v #{vault_name}"
342
+ return
343
+ end
344
+
345
+ unless team_data[:owner] == Config.inventlist_handle
346
+ $stderr.puts "Error: Only the vault owner (@#{team_data[:owner]}) can rotate keys."
347
+ return
348
+ end
349
+
350
+ key_slots = team_data[:key_slots]
351
+ vault_owner = team_data[:owner]
352
+
353
+ master_key = SessionCache.get(vault_name)
354
+ unless master_key
355
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked."
356
+ return
357
+ end
358
+
359
+ passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
360
+ if passphrase.nil? || passphrase.empty?
361
+ $stderr.puts "Error: Passphrase cannot be empty."
362
+ return
363
+ end
364
+
365
+ vault = Vault.new(name: vault_name, master_key: master_key)
366
+ secrets = vault.all
367
+ store = Store.new(vault_name)
368
+
369
+ new_salt = Crypto.generate_salt
370
+ new_master_key = Crypto.derive_master_key(passphrase, new_salt)
371
+
372
+ store.write_encrypted(Crypto.encrypt(JSON.generate(secrets), new_master_key))
373
+ store.create_meta!(salt: new_salt)
374
+
375
+ new_slots = {}
376
+ key_slots.each do |h, slot|
377
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
378
+ if slot["scopes"].is_a?(Array)
379
+ filtered = vault.filter(slot["scopes"])
380
+ member_key = RbNaCl::Random.random_bytes(32)
381
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
382
+ new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(member_key, slot["pub"]), "scopes" => slot["scopes"], "blob" => Base64.strict_encode64(encrypted_blob) }
383
+ else
384
+ new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
385
+ end
386
+ end
387
+
388
+ blob = SyncBundle.pack_v3(store, owner: vault_owner, key_slots: new_slots)
389
+ client.push_vault(vault_name, blob)
390
+ SessionCache.set(vault_name, new_master_key)
391
+
392
+ $stdout.puts "Vault '#{vault_name}' re-encrypted with new master key."
393
+ $stdout.puts "#{new_slots.size} member(s) updated."
394
+ rescue ApiClient::ApiError => e
395
+ $stderr.puts "Error: #{e.message}"
396
+ end
397
+
398
+ private
399
+
400
+ def prompt_passphrase(msg = "Passphrase: ")
401
+ IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
402
+ rescue Interrupt
403
+ $stderr.puts
404
+ ""
405
+ end
406
+
407
+ def load_key_slots(client, vault_name)
408
+ data = load_team_data(client, vault_name)
409
+ data ? data[:key_slots] : nil
410
+ end
411
+
412
+ # Load full bundle data including owner. Returns nil if no remote or not a team vault.
413
+ def load_team_data(client, vault_name)
414
+ return nil unless client.respond_to?(:pull_vault)
415
+ blob = client.pull_vault(vault_name)
416
+ return nil unless blob.is_a?(String) && !blob.empty?
417
+ data = SyncBundle.unpack(blob)
418
+ return nil unless data[:key_slots].is_a?(Hash)
419
+ data
420
+ rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
421
+ nil
422
+ end
423
+
424
+ # Remove a member's key slot, optionally rotating the vault master key.
425
+ # Supports partial scope removal via remove_scopes.
426
+ def remove_key_slot(handle, vault_name, key_slots, client, rotate: false, remove_scopes: nil, owner: nil)
427
+ owner ||= Config.inventlist_handle
428
+ unless key_slots.key?(handle)
429
+ $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
430
+ return
431
+ end
432
+
433
+ store = Store.new(vault_name)
434
+
435
+ # Partial scope removal
436
+ if remove_scopes && key_slots[handle].is_a?(Hash) && key_slots[handle]["scopes"].is_a?(Array)
437
+ remaining = key_slots[handle]["scopes"] - remove_scopes
438
+ if remaining.empty?
439
+ # Last scope removed — remove member entirely
440
+ key_slots.delete(handle)
441
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}' (last scope removed)."
442
+ else
443
+ # Rebuild blob with remaining scopes
444
+ master_key = SessionCache.get(vault_name)
445
+ if master_key
446
+ vault = Vault.new(name: vault_name, master_key: master_key)
447
+ filtered = vault.filter(remaining)
448
+ member_key = RbNaCl::Random.random_bytes(32)
449
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
450
+ enc_key = KeySlot.create(member_key, key_slots[handle]["pub"])
451
+ key_slots[handle] = {
452
+ "pub" => key_slots[handle]["pub"],
453
+ "enc_key" => enc_key,
454
+ "scopes" => remaining,
455
+ "blob" => Base64.strict_encode64(encrypted_blob)
456
+ }
457
+ end
458
+ $stdout.puts "Removed scope(s) #{remove_scopes.join(", ")} from @#{handle}. Remaining: #{remaining.join(", ")}"
459
+ end
460
+
461
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
462
+ client.push_vault(vault_name, blob)
463
+ return
464
+ end
465
+
466
+ # Full member removal
467
+ valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
468
+ if handle == Config.inventlist_handle && valid_slots.size <= 1
469
+ $stderr.puts "Error: Cannot remove yourself — you are the only member."
470
+ return
471
+ end
472
+
473
+ key_slots.delete(handle)
474
+
475
+ if rotate
476
+ master_key = SessionCache.get(vault_name)
477
+ unless master_key
478
+ $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
479
+ return
480
+ end
481
+
482
+ # Prompt for new passphrase
483
+ passphrase = prompt_passphrase("New passphrase for vault '#{vault_name}': ")
484
+ if passphrase.nil? || passphrase.empty?
485
+ $stderr.puts "Error: Passphrase cannot be empty."
486
+ return
487
+ end
488
+
489
+ vault = Vault.new(name: vault_name, master_key: master_key)
490
+ secrets = vault.all
491
+
492
+ new_salt = Crypto.generate_salt
493
+ new_master_key = Crypto.derive_master_key(passphrase, new_salt)
494
+
495
+ new_json = JSON.generate(secrets)
496
+ new_encrypted = Crypto.encrypt(new_json, new_master_key)
497
+ store.write_encrypted(new_encrypted)
498
+ store.create_meta!(salt: new_salt)
499
+
500
+ new_slots = {}
501
+ key_slots.each do |h, slot|
502
+ next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
503
+ if slot["scopes"].is_a?(Array)
504
+ # Scoped member — rebuild per-member blob
505
+ filtered = vault.filter(slot["scopes"])
506
+ member_key = RbNaCl::Random.random_bytes(32)
507
+ encrypted_blob = Crypto.encrypt(JSON.generate(filtered), member_key)
508
+ new_slots[h] = {
509
+ "pub" => slot["pub"],
510
+ "enc_key" => KeySlot.create(member_key, slot["pub"]),
511
+ "scopes" => slot["scopes"],
512
+ "blob" => Base64.strict_encode64(encrypted_blob)
513
+ }
514
+ else
515
+ # Full-access member
516
+ new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]), "scopes" => nil, "blob" => nil }
517
+ end
518
+ end
519
+
520
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
521
+ client.push_vault(vault_name, blob)
522
+
523
+ if new_slots.key?(Config.inventlist_handle)
524
+ SessionCache.set(vault_name, new_master_key)
525
+ else
526
+ SessionCache.clear(vault_name)
527
+ end
528
+
529
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
530
+ $stdout.puts "Vault re-encrypted with new master key (rotated)."
531
+ else
532
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
533
+ client.push_vault(vault_name, blob)
534
+ $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
535
+ end
536
+ end
537
+
538
+ def list_key_slots(vault_name, key_slots)
539
+ my_handle = Config.inventlist_handle
540
+ valid = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
541
+
542
+ if valid.empty?
543
+ $stdout.puts "No key slots for vault '#{vault_name}'."
544
+ return
545
+ end
546
+
547
+ $stdout.puts "Vault: #{vault_name} — #{valid.size} member(s)"
548
+ $stdout.puts
549
+ valid.sort.each do |handle, slot|
550
+ marker = handle == my_handle ? " (you)" : ""
551
+ $stdout.puts " @#{handle}#{marker}"
552
+ end
553
+ end
64
554
  end
65
555
  end
66
556
  end
@@ -8,6 +8,58 @@ module LocalVault
8
8
  class CLI < Thor
9
9
  class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
10
10
 
11
+ def self.help(shell, subcommand = false)
12
+ shell.say ""
13
+ shell.say "LocalVault — encrypted local secrets vault with MCP support for AI agents"
14
+ shell.say " https://inventlist.com/tools/localvault"
15
+ shell.say ""
16
+ shell.say "GETTING STARTED"
17
+ shell.say " localvault login [TOKEN] Log in to InventList (enables sync + team features)"
18
+ shell.say " localvault init [NAME] Create a new encrypted vault"
19
+ shell.say " localvault demo Create a demo vault to explore commands"
20
+ shell.say ""
21
+ shell.say "SECRETS"
22
+ shell.say " localvault set KEY VALUE Store a secret"
23
+ shell.say " localvault get KEY Retrieve a secret"
24
+ shell.say " localvault show Display all secrets (masked by default)"
25
+ shell.say " localvault list List secret key names"
26
+ shell.say " localvault delete KEY Remove a secret"
27
+ shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
28
+ shell.say " localvault env Export as shell variable assignments"
29
+ shell.say " localvault exec -- CMD Run a command with secrets injected"
30
+ shell.say ""
31
+ shell.say "VAULT MANAGEMENT"
32
+ shell.say " localvault vaults List all vaults"
33
+ shell.say " localvault switch [VAULT] Switch default vault"
34
+ shell.say " localvault rekey [NAME] Change vault passphrase"
35
+ shell.say " localvault unlock Cache passphrase for session"
36
+ shell.say " localvault lock [NAME] Clear cached passphrase"
37
+ shell.say " localvault reset [NAME] Destroy and reinitialize a vault"
38
+ shell.say ""
39
+ shell.say "TEAM & SYNC (requires localvault login)"
40
+ shell.say " localvault sync push [NAME] Push vault to cloud"
41
+ shell.say " localvault sync pull [NAME] Pull vault from cloud"
42
+ shell.say " localvault sync status Show sync status"
43
+ shell.say " localvault team add HANDLE Add teammate (use --scope KEY... for partial access)"
44
+ shell.say " localvault team remove HANDLE Remove teammate"
45
+ shell.say " localvault team list List vault members"
46
+ shell.say " localvault team init Convert vault to team vault (required before team add)"
47
+ shell.say " localvault team rotate Re-key vault, keep all members"
48
+ shell.say " localvault keys generate Generate X25519 identity keypair"
49
+ shell.say " localvault keys publish Publish public key so others can share vaults with you"
50
+ shell.say ""
51
+ shell.say "AI / MCP"
52
+ shell.say " localvault install-mcp Configure MCP server in your AI tool"
53
+ shell.say " localvault mcp Start MCP server (stdio)"
54
+ shell.say ""
55
+ shell.say "OTHER"
56
+ shell.say " localvault login --status Show current login status"
57
+ shell.say " localvault logout Log out"
58
+ shell.say " localvault version Print version"
59
+ shell.say " localvault help [COMMAND] Full help for any command"
60
+ shell.say ""
61
+ end
62
+
11
63
  desc "init [NAME]", "Create a new vault"
12
64
  def init(name = nil)
13
65
  vault_name = name || Config.default_vault
@@ -487,8 +539,15 @@ module LocalVault
487
539
  end
488
540
 
489
541
  unless token
490
- $stdout.puts "Usage: localvault login TOKEN"
542
+ $stdout.puts "Usage: localvault login YOUR_TOKEN"
543
+ $stdout.puts
491
544
  $stdout.puts "Get your token at: https://inventlist.com/settings"
545
+ $stdout.puts "New to InventList? Sign up free at https://inventlist.com"
546
+ $stdout.puts
547
+ $stdout.puts "LocalVault sync and team features require a free InventList account."
548
+ $stdout.puts "Local vault encryption works without an account."
549
+ $stdout.puts
550
+ $stdout.puts "Docs: https://inventlist.com/sites/localvault/series/localvault"
492
551
  return
493
552
  end
494
553
 
@@ -547,7 +606,7 @@ module LocalVault
547
606
  desc: "Recipient: @handle, team:HANDLE, or crew:SLUG"
548
607
  def share(vault_name = nil)
549
608
  unless Config.token
550
- abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
609
+ abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
551
610
  return
552
611
  end
553
612
 
@@ -590,7 +649,7 @@ module LocalVault
590
649
  desc "receive", "Fetch and import vaults shared with you"
591
650
  def receive
592
651
  unless Config.token
593
- abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
652
+ abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
594
653
  return
595
654
  end
596
655
 
@@ -658,7 +717,7 @@ module LocalVault
658
717
  desc "revoke SHARE_ID", "Revoke a vault share (stops future access)"
659
718
  def revoke(share_id)
660
719
  unless Config.token
661
- abort_with "Not connected. Run: localvault connect --token TOKEN --handle HANDLE"
720
+ abort_with "Not logged in. Run: localvault login YOUR_TOKEN\n Get your token at: https://inventlist.com/settings"
662
721
  return
663
722
  end
664
723