localvault 1.1.1 → 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,13 +1,99 @@
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
 
@@ -45,9 +131,17 @@ module LocalVault
45
131
 
46
132
  desc "add HANDLE", "Add a teammate to a synced vault via key slot"
47
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).
48
140
  def add(handle)
49
141
  unless Config.token
50
- $stderr.puts "Error: Not logged in. Run: localvault login 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"
51
145
  return
52
146
  end
53
147
 
@@ -58,55 +152,99 @@ module LocalVault
58
152
 
59
153
  handle = handle.delete_prefix("@")
60
154
  vault_name = options[:vault] || Config.default_vault
155
+ scope_list = options[:scope]
61
156
 
62
- # Need master key from session
63
157
  master_key = SessionCache.get(vault_name)
64
158
  unless master_key
65
159
  $stderr.puts "Error: Vault '#{vault_name}' is not unlocked. Run: localvault show -v #{vault_name}"
66
160
  return
67
161
  end
68
162
 
69
- # Fetch recipient's public key
70
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
71
195
  result = client.get_public_key(handle)
72
196
  pub_key = result["public_key"]
73
-
74
197
  unless pub_key && !pub_key.empty?
75
198
  $stderr.puts "Error: @#{handle} has no public key published."
76
199
  return
77
200
  end
78
201
 
79
- # Load existing key slots from remote
80
- existing_blob = client.pull_vault(vault_name) rescue nil
81
- key_slots = if existing_blob.is_a?(String) && !existing_blob.empty?
82
- data = SyncBundle.unpack(existing_blob)
83
- data[:key_slots].is_a?(Hash) ? data[:key_slots] : {}
84
- else
85
- {}
86
- end
87
-
88
- # Create key slot for recipient
89
- begin
90
- enc_key = KeySlot.create(master_key, pub_key)
91
- rescue ArgumentError, KeySlot::DecryptionError => e
92
- $stderr.puts "Error: @#{handle}'s public key is invalid: #{e.message}"
93
- return
94
- end
95
- key_slots[handle] = { "pub" => pub_key, "enc_key" => enc_key }
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
96
206
 
97
- # Ensure owner slot exists too
98
- owner_handle = Config.inventlist_handle
99
- unless key_slots.key?(owner_handle)
100
- owner_pub = Identity.public_key
101
- key_slots[owner_handle] = { "pub" => owner_pub, "enc_key" => KeySlot.create(master_key, owner_pub) }
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 }
102
237
  end
103
238
 
104
- # Pack and push
105
239
  store = Store.new(vault_name)
106
- blob = SyncBundle.pack(store, key_slots: key_slots)
240
+ blob = SyncBundle.pack_v3(store, owner: data[:owner], key_slots: key_slots)
107
241
  client.push_vault(vault_name, blob)
108
242
 
109
- $stdout.puts "Added @#{handle} to vault '#{vault_name}'."
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
110
248
  rescue ApiClient::ApiError => e
111
249
  if e.status == 404
112
250
  $stderr.puts "Error: @#{handle} not found or has no public key."
@@ -120,9 +258,22 @@ module LocalVault
120
258
  desc "remove HANDLE", "Remove a person's access to a vault"
121
259
  method_option :vault, type: :string, aliases: "-v"
122
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.
123
268
  def remove(handle)
124
269
  unless Config.token
125
- $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"
126
277
  return
127
278
  end
128
279
 
@@ -131,9 +282,20 @@ module LocalVault
131
282
  client = ApiClient.new(token: Config.token)
132
283
 
133
284
  # Try sync-based key slot removal first
134
- key_slots = load_key_slots(client, vault_name)
135
- if key_slots && !key_slots.empty?
136
- remove_key_slot(handle, vault_name, key_slots, client, rotate: options[:rotate])
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])
137
299
  return
138
300
  end
139
301
 
@@ -154,25 +316,154 @@ module LocalVault
154
316
  $stderr.puts "Error: #{e.message}"
155
317
  end
156
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
+
157
398
  private
158
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
+
159
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)
160
414
  return nil unless client.respond_to?(:pull_vault)
161
415
  blob = client.pull_vault(vault_name)
162
416
  return nil unless blob.is_a?(String) && !blob.empty?
163
417
  data = SyncBundle.unpack(blob)
164
- slots = data[:key_slots]
165
- slots.is_a?(Hash) ? slots : nil
418
+ return nil unless data[:key_slots].is_a?(Hash)
419
+ data
166
420
  rescue ApiClient::ApiError, SyncBundle::UnpackError, NoMethodError
167
421
  nil
168
422
  end
169
423
 
170
- def remove_key_slot(handle, vault_name, key_slots, client, rotate: false)
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
171
428
  unless key_slots.key?(handle)
172
429
  $stderr.puts "Error: @#{handle} has no slot in vault '#{vault_name}'."
173
430
  return
174
431
  end
175
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
176
467
  valid_slots = key_slots.select { |_, v| v.is_a?(Hash) && v["pub"].is_a?(String) }
177
468
  if handle == Config.inventlist_handle && valid_slots.size <= 1
178
469
  $stderr.puts "Error: Cannot remove yourself — you are the only member."
@@ -180,7 +471,6 @@ module LocalVault
180
471
  end
181
472
 
182
473
  key_slots.delete(handle)
183
- store = Store.new(vault_name)
184
474
 
185
475
  if rotate
186
476
  master_key = SessionCache.get(vault_name)
@@ -189,30 +479,47 @@ module LocalVault
189
479
  return
190
480
  end
191
481
 
192
- # Decrypt current secrets, generate new master key, re-encrypt
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
+
193
489
  vault = Vault.new(name: vault_name, master_key: master_key)
194
490
  secrets = vault.all
195
491
 
196
492
  new_salt = Crypto.generate_salt
197
- new_master_key = Crypto.derive_master_key(SecureRandom.hex(32), new_salt)
493
+ new_master_key = Crypto.derive_master_key(passphrase, new_salt)
198
494
 
199
- # Re-encrypt secrets with new master key
200
495
  new_json = JSON.generate(secrets)
201
496
  new_encrypted = Crypto.encrypt(new_json, new_master_key)
202
497
  store.write_encrypted(new_encrypted)
203
498
  store.create_meta!(salt: new_salt)
204
499
 
205
- # Re-create key slots for remaining members with new master key
206
500
  new_slots = {}
207
501
  key_slots.each do |h, slot|
208
502
  next unless slot.is_a?(Hash) && slot["pub"].is_a?(String)
209
- new_slots[h] = { "pub" => slot["pub"], "enc_key" => KeySlot.create(new_master_key, slot["pub"]) }
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
210
518
  end
211
519
 
212
- blob = SyncBundle.pack(store, key_slots: new_slots)
520
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: new_slots)
213
521
  client.push_vault(vault_name, blob)
214
522
 
215
- # Only cache new master key if the caller is still a member
216
523
  if new_slots.key?(Config.inventlist_handle)
217
524
  SessionCache.set(vault_name, new_master_key)
218
525
  else
@@ -222,7 +529,7 @@ module LocalVault
222
529
  $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
223
530
  $stdout.puts "Vault re-encrypted with new master key (rotated)."
224
531
  else
225
- blob = SyncBundle.pack(store, key_slots: key_slots)
532
+ blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
226
533
  client.push_vault(vault_name, blob)
227
534
  $stdout.puts "Removed @#{handle} from vault '#{vault_name}'."
228
535
  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